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:
John
2026-06-15 15:47:19 -07:00
parent 2e14873f2d
commit 3cd322ccfe
19 changed files with 2141 additions and 156 deletions
+380 -29
View File
@@ -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();
}