Add WeChat login binding gate and context-aware auth UX.
Stop auto-creating duplicate accounts on OAuth, add bind-or-register gate, PC scan login, mobile open-in-WeChat guide, and fix localhost session cookies. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+380
-29
@@ -1519,6 +1519,194 @@ export function createUserAuth(pool, options = {}) {
|
||||
await repairAllUserPublishDirs();
|
||||
};
|
||||
|
||||
const PENDING_BIND_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
const issueUserSession = async (userId, role, now = Date.now()) => {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await storeSession(userId, role, token, now);
|
||||
return token;
|
||||
};
|
||||
|
||||
const findBindingByOpenid = async (appId, openid) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT wi.user_id, u.status
|
||||
FROM h5_user_wechat_identities wi
|
||||
JOIN h5_users u ON u.id = wi.user_id
|
||||
WHERE wi.app_id = ? AND wi.openid = ?
|
||||
LIMIT 1`,
|
||||
[appId, openid],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const findBindingByUnionid = async (unionid) => {
|
||||
if (!unionid) return null;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT wi.user_id, wi.app_id, u.status
|
||||
FROM h5_user_wechat_identities wi
|
||||
JOIN h5_users u ON u.id = wi.user_id
|
||||
WHERE wi.unionid = ?
|
||||
LIMIT 1`,
|
||||
[unionid],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const getWechatBindingForUser = async (userId, appId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, nickname, avatar_url, last_login_at, created_at
|
||||
FROM h5_user_wechat_identities
|
||||
WHERE user_id = ? AND app_id = ?
|
||||
LIMIT 1`,
|
||||
[userId, appId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const bindWechatToUser = async ({
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const existingOpenid = await findBindingByOpenid(appId, openid);
|
||||
if (existingOpenid && existingOpenid.user_id !== userId) {
|
||||
return { ok: false, message: '该微信已绑定其他账号,请先用该账号登录' };
|
||||
}
|
||||
const existingUserBind = await getWechatBindingForUser(userId, appId);
|
||||
if (existingUserBind) {
|
||||
return { ok: false, message: '你的账号已绑定其他微信,需先解绑后再试' };
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO h5_user_wechat_identities
|
||||
(id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
return { ok: false, message: '微信绑定冲突,请重试' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const touchWechatIdentity = async ({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
await pool.query(
|
||||
`UPDATE h5_user_wechat_identities
|
||||
SET nickname = COALESCE(?, nickname),
|
||||
avatar_url = COALESCE(?, avatar_url),
|
||||
unionid = COALESCE(?, unionid),
|
||||
last_login_at = ?,
|
||||
updated_at = ?
|
||||
WHERE app_id = ? AND openid = ?`,
|
||||
[nickname, avatarUrl, unionid, now, now, appId, openid],
|
||||
);
|
||||
};
|
||||
|
||||
const pruneWechatPendingBinds = async (now = Date.now()) => {
|
||||
await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]);
|
||||
};
|
||||
|
||||
const createWechatPendingBind = async ({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
returnTo = '/',
|
||||
utmSource = null,
|
||||
utmMedium = null,
|
||||
utmCampaign = null,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
await pruneWechatPendingBinds(now);
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
await pool.query(
|
||||
`INSERT INTO h5_wechat_pending_binds
|
||||
(token, app_id, openid, unionid, nickname, avatar_url, return_to,
|
||||
utm_source, utm_medium, utm_campaign, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
token,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
now + PENDING_BIND_TTL_MS,
|
||||
now,
|
||||
],
|
||||
);
|
||||
return token;
|
||||
};
|
||||
|
||||
const getWechatPendingBind = async (token, now = Date.now()) => {
|
||||
if (!token) return null;
|
||||
await pruneWechatPendingBinds(now);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to,
|
||||
utm_source, utm_medium, utm_campaign, expires_at
|
||||
FROM h5_wechat_pending_binds
|
||||
WHERE token = ?
|
||||
LIMIT 1`,
|
||||
[token],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row || Number(row.expires_at) <= now) return null;
|
||||
return row;
|
||||
};
|
||||
|
||||
const consumeWechatPendingBind = async (token) => {
|
||||
await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]);
|
||||
};
|
||||
|
||||
const loginBoundWechatUser = async ({
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now });
|
||||
const user = await getUserById(userId);
|
||||
if (!user || user.status === 'disabled') {
|
||||
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||
}
|
||||
const token = await issueUserSession(user.id, user.role, now);
|
||||
return { ok: true, token, user: publicUser(user), isNewUser: false };
|
||||
};
|
||||
|
||||
const generateWechatUsername = async (openid) => {
|
||||
const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
const suffix = cleaned.slice(-8) || crypto.randomBytes(4).toString('hex');
|
||||
@@ -1621,70 +1809,223 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const loginByWechat = async ({
|
||||
const resolveWechatAuth = async ({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
ip = 'unknown',
|
||||
intent = 'login',
|
||||
bindUserId = null,
|
||||
returnTo = '/',
|
||||
utmSource = null,
|
||||
utmMedium = null,
|
||||
utmCampaign = null,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const [bindings] = await pool.query(
|
||||
`SELECT wi.user_id, u.status
|
||||
FROM h5_user_wechat_identities wi
|
||||
JOIN h5_users u ON u.id = wi.user_id
|
||||
WHERE wi.app_id = ? AND wi.openid = ?
|
||||
LIMIT 1`,
|
||||
[appId, openid],
|
||||
);
|
||||
const binding = bindings[0];
|
||||
let binding = await findBindingByOpenid(appId, openid);
|
||||
|
||||
if (!binding && unionid) {
|
||||
const unionBinding = await findBindingByUnionid(unionid);
|
||||
if (unionBinding) {
|
||||
const linked = await bindWechatToUser({
|
||||
userId: unionBinding.user_id,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
});
|
||||
if (!linked.ok) return linked;
|
||||
binding = { user_id: unionBinding.user_id, status: unionBinding.status };
|
||||
}
|
||||
}
|
||||
|
||||
if (binding) {
|
||||
if (binding.status === 'disabled') {
|
||||
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE h5_user_wechat_identities
|
||||
SET nickname = COALESCE(?, nickname),
|
||||
avatar_url = COALESCE(?, avatar_url),
|
||||
unionid = COALESCE(?, unionid),
|
||||
last_login_at = ?,
|
||||
updated_at = ?
|
||||
WHERE app_id = ? AND openid = ?`,
|
||||
[nickname, avatarUrl, unionid, now, now, appId, openid],
|
||||
);
|
||||
const user = await getUserById(binding.user_id);
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await storeSession(user.id, user.role, token, now);
|
||||
return { ok: true, token, user: publicUser(user), isNewUser: false };
|
||||
return loginBoundWechatUser({
|
||||
userId: binding.user_id,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
}).then((result) => (result.ok ? { ...result, action: 'login' } : result));
|
||||
}
|
||||
|
||||
const registered = await registerViaWechat({
|
||||
if (intent === 'bind' && bindUserId) {
|
||||
const user = await getUserById(bindUserId);
|
||||
if (!user) return { ok: false, message: '用户不存在' };
|
||||
if (user.status === 'disabled') {
|
||||
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||
}
|
||||
const bound = await bindWechatToUser({
|
||||
userId: bindUserId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
});
|
||||
if (!bound.ok) return bound;
|
||||
const token = await issueUserSession(user.id, user.role, now);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'login',
|
||||
token,
|
||||
user: publicUser(user),
|
||||
isNewUser: false,
|
||||
bound: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'register') {
|
||||
const registered = await registerViaWechat({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
});
|
||||
if (!registered.ok) return registered;
|
||||
const token = await issueUserSession(registered.user.id, registered.user.role, now);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'login',
|
||||
token,
|
||||
user: registered.user,
|
||||
isNewUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingToken = await createWechatPendingBind({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
now,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
action: 'binding_gate',
|
||||
pendingToken,
|
||||
wechatProfile: {
|
||||
nickname: nickname ?? null,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
},
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
};
|
||||
};
|
||||
|
||||
const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => {
|
||||
const pending = await getWechatPendingBind(pendingToken, now);
|
||||
if (!pending) {
|
||||
return { ok: false, message: '绑定会话已过期,请重新微信登录' };
|
||||
}
|
||||
const registered = await registerViaWechat({
|
||||
appId: pending.app_id,
|
||||
openid: pending.openid,
|
||||
unionid: pending.unionid,
|
||||
nickname: pending.nickname,
|
||||
avatarUrl: pending.avatar_url,
|
||||
now,
|
||||
});
|
||||
if (!registered.ok) return registered;
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await storeSession(registered.user.id, registered.user.role, token, now);
|
||||
await consumeWechatPendingBind(pendingToken);
|
||||
const token = await issueUserSession(registered.user.id, registered.user.role, now);
|
||||
return {
|
||||
ok: true,
|
||||
token,
|
||||
user: registered.user,
|
||||
isNewUser: true,
|
||||
returnTo: pending.return_to || '/',
|
||||
utmSource: pending.utm_source,
|
||||
utmMedium: pending.utm_medium,
|
||||
utmCampaign: pending.utm_campaign,
|
||||
};
|
||||
};
|
||||
|
||||
const completeWechatBindAccount = async ({
|
||||
pendingToken,
|
||||
username,
|
||||
password,
|
||||
ip = 'unknown',
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const pending = await getWechatPendingBind(pendingToken, now);
|
||||
if (!pending) {
|
||||
return { ok: false, message: '绑定会话已过期,请重新微信登录' };
|
||||
}
|
||||
const loginResult = await login({ username, password, ip, now });
|
||||
if (!loginResult.ok) return loginResult;
|
||||
|
||||
const bound = await bindWechatToUser({
|
||||
userId: loginResult.user.id,
|
||||
appId: pending.app_id,
|
||||
openid: pending.openid,
|
||||
unionid: pending.unionid,
|
||||
nickname: pending.nickname,
|
||||
avatarUrl: pending.avatar_url,
|
||||
now,
|
||||
});
|
||||
if (!bound.ok) return bound;
|
||||
|
||||
await consumeWechatPendingBind(pendingToken);
|
||||
return {
|
||||
ok: true,
|
||||
token: loginResult.token,
|
||||
user: loginResult.user,
|
||||
isNewUser: false,
|
||||
bound: true,
|
||||
returnTo: pending.return_to || '/',
|
||||
};
|
||||
};
|
||||
|
||||
const getWechatBindingStatus = async (userId, appId) => {
|
||||
const row = await getWechatBindingForUser(userId, appId);
|
||||
if (!row) return { bound: false };
|
||||
return {
|
||||
bound: true,
|
||||
nickname: row.nickname,
|
||||
avatarUrl: row.avatar_url,
|
||||
lastLoginAt: Number(row.last_login_at),
|
||||
boundAt: Number(row.created_at),
|
||||
};
|
||||
};
|
||||
|
||||
const loginByWechat = async (params) => {
|
||||
const result = await resolveWechatAuth({ ...params, intent: 'login' });
|
||||
if (!result.ok) return result;
|
||||
if (result.action === 'binding_gate') {
|
||||
return { ok: false, message: '需要完成账号绑定' };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
USER_COOKIE,
|
||||
register,
|
||||
login,
|
||||
loginByWechat,
|
||||
resolveWechatAuth,
|
||||
completeWechatRegister,
|
||||
completeWechatBindAccount,
|
||||
getWechatPendingBind,
|
||||
getWechatBindingStatus,
|
||||
resetPassword,
|
||||
verify,
|
||||
revoke,
|
||||
@@ -1785,3 +2126,13 @@ export function resolveCookieDomain() {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */
|
||||
export function resolveCookieDomainForRequest(req) {
|
||||
const hostHeader = req?.get?.('host') ?? req?.hostname ?? '';
|
||||
const hostname = String(hostHeader).split(':')[0].toLowerCase();
|
||||
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||
return null;
|
||||
}
|
||||
return resolveCookieDomain();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user