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
+223 -37
View File
@@ -2,10 +2,13 @@ import crypto from 'node:crypto';
import { fetch } from 'undici';
const OAUTH_AUTHORIZE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize';
const OAUTH_QRCONNECT_URL = 'https://open.weixin.qq.com/connect/qrconnect';
const OAUTH_ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token';
const OAUTH_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo';
const STATE_TTL_MS = 10 * 60 * 1000;
const VALID_INTENTS = new Set(['login', 'register', 'bind']);
const VALID_AUTH_MODES = new Set(['mp', 'open']);
function resolveCallbackUrl(env = process.env) {
const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? '';
@@ -24,13 +27,19 @@ export function loadWechatOAuthConfig(env = process.env) {
const enabledFlag = env.H5_WECHAT_OAUTH_ENABLED === '1';
const appId = env.H5_WECHAT_APP_ID?.trim() ?? env.H5_WECHAT_OAUTH_APP_ID?.trim() ?? '';
const appSecret = env.H5_WECHAT_APP_SECRET?.trim() ?? env.H5_WECHAT_OAUTH_APP_SECRET?.trim() ?? '';
const openAppId = env.H5_WECHAT_OPEN_APP_ID?.trim() ?? '';
const openAppSecret = env.H5_WECHAT_OPEN_APP_SECRET?.trim() ?? '';
const scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || 'snsapi_userinfo';
const callbackUrl = resolveCallbackUrl(env);
const configured = Boolean(appId && appSecret && callbackUrl);
const scanConfigured = Boolean(openAppId && openAppSecret && callbackUrl);
return {
enabled: enabledFlag && configured,
appId,
appSecret,
openAppId,
openAppSecret,
scanEnabled: enabledFlag && scanConfigured,
scope,
callbackUrl,
};
@@ -50,21 +59,34 @@ export function sanitizeWechatReturnTo(returnTo, req) {
}
}
function buildAuthorizeUrl(config, { state }) {
const params = new URLSearchParams({
appid: config.appId,
redirect_uri: config.callbackUrl,
response_type: 'code',
scope: config.scope,
state,
});
return `${OAUTH_AUTHORIZE_URL}?${params.toString()}#wechat_redirect`;
function normalizeIntent(intent) {
const value = typeof intent === 'string' ? intent.trim().toLowerCase() : 'login';
return VALID_INTENTS.has(value) ? value : 'login';
}
async function exchangeCode(config, code) {
function normalizeAuthMode(authMode) {
const value = typeof authMode === 'string' ? authMode.trim().toLowerCase() : 'mp';
return VALID_AUTH_MODES.has(value) ? value : 'mp';
}
function buildAuthorizeUrl(config, { state, authMode = 'mp' }) {
const isOpen = authMode === 'open';
const params = new URLSearchParams({
appid: config.appId,
secret: config.appSecret,
appid: isOpen ? config.openAppId : config.appId,
redirect_uri: config.callbackUrl,
response_type: 'code',
scope: isOpen ? 'snsapi_login' : config.scope,
state,
});
const base = isOpen ? OAUTH_QRCONNECT_URL : OAUTH_AUTHORIZE_URL;
return `${base}?${params.toString()}#wechat_redirect`;
}
async function exchangeCode(config, code, authMode = 'mp') {
const isOpen = authMode === 'open';
const params = new URLSearchParams({
appid: isOpen ? config.openAppId : config.appId,
secret: isOpen ? config.openAppSecret : config.appSecret,
code,
grant_type: 'authorization_code',
});
@@ -81,6 +103,7 @@ async function exchangeCode(config, code) {
openid: data.openid,
unionid: data.unionid ?? null,
refreshToken: data.refresh_token ?? null,
appId: isOpen ? config.openAppId : config.appId,
};
}
@@ -107,7 +130,7 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
return {
enabled: false,
publicConfig() {
return { enabled: false, inWechat: false };
return { enabled: false, inWechat: false, scanEnabled: false };
},
};
}
@@ -122,6 +145,9 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
utmSource = null,
utmMedium = null,
utmCampaign = null,
intent = 'login',
bindUserId = null,
authMode = 'mp',
now = Date.now(),
} = {}) => {
const state = crypto.randomBytes(24).toString('base64url');
@@ -129,93 +155,246 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
await pruneExpiredStates(now);
await pool.query(
`INSERT INTO h5_wechat_oauth_states
(state, return_to, utm_source, utm_medium, utm_campaign, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[state, returnTo, utmSource, utmMedium, utmCampaign, now + STATE_TTL_MS, now],
(state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id,
auth_mode, status, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
[
state,
returnTo,
utmSource,
utmMedium,
utmCampaign,
normalizeIntent(intent),
bindUserId,
normalizeAuthMode(authMode),
now + STATE_TTL_MS,
now,
],
);
}
return state;
};
const consumeState = async (state, now = Date.now()) => {
if (!state) return null;
if (!pool) return null;
const loadState = async (state, now = Date.now()) => {
if (!state || !pool) return null;
const [rows] = await pool.query(
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, expires_at
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id,
auth_mode, status, result_kind, result_token, result_message, expires_at
FROM h5_wechat_oauth_states
WHERE state = ?
LIMIT 1`,
[state],
);
const row = rows[0];
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
if (!row || Number(row.expires_at) <= now) return null;
return row;
};
const consumeState = async (state, now = Date.now()) => {
const row = await loadState(state, now);
if (!row) return null;
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
return {
returnTo: row.return_to || '/',
utmSource: row.utm_source,
utmMedium: row.utm_medium,
utmCampaign: row.utm_campaign,
intent: row.intent || 'login',
bindUserId: row.bind_user_id,
authMode: row.auth_mode || 'mp',
};
};
const buildAuthorizeRedirect = async (req) => {
const markStateComplete = async (
state,
{ resultKind, resultToken = null, resultMessage = null },
now = Date.now(),
) => {
await pool.query(
`UPDATE h5_wechat_oauth_states
SET status = 'done', result_kind = ?, result_token = ?, result_message = ?, expires_at = ?
WHERE state = ?`,
[resultKind, resultToken, resultMessage, now + STATE_TTL_MS, state],
);
};
const buildAuthorizeRedirect = async (req, { bindUserId = null } = {}) => {
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
const intent = normalizeIntent(req.query?.intent);
const authMode = normalizeAuthMode(req.query?.auth_mode);
if (authMode === 'open' && !config.scanEnabled) {
throw new Error('微信扫码登录未配置');
}
const state = await createState({
returnTo,
utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null,
utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null,
utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null,
intent,
bindUserId,
authMode,
});
return buildAuthorizeUrl(config, { state, authMode });
};
const startScanLogin = async (req) => {
if (!config.scanEnabled) {
throw new Error('微信扫码登录未配置');
}
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
const state = await createState({
returnTo,
utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null,
utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null,
utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null,
intent: 'login',
authMode: 'open',
});
return buildAuthorizeUrl(config, { state });
return {
state,
qrUrl: buildAuthorizeUrl(config, { state, authMode: 'open' }),
expiresInMs: STATE_TTL_MS,
};
};
const pollScanLogin = async (state, now = Date.now()) => {
const row = await loadState(state, now);
if (!row) return { status: 'expired' };
if (row.status !== 'done') return { status: 'pending' };
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
if (row.result_kind === 'error') {
return { status: 'error', message: row.result_message || '微信登录失败' };
}
if (row.result_kind === 'binding_gate') {
return { status: 'binding_gate', pendingToken: row.result_token };
}
if (row.result_kind === 'login') {
return { status: 'complete', token: row.result_token };
}
return { status: 'error', message: '微信登录结果无效' };
};
const handleCallback = async ({ code, state, ip = 'unknown' }) => {
if (!code) {
throw new Error('缺少微信授权 code');
}
const stateRow = await consumeState(state);
const stateRow = await loadState(state);
if (!stateRow) {
throw new Error('授权状态无效或已过期,请重新登录');
}
const tokenInfo = await exchangeCode(config, code);
const authMode = stateRow.auth_mode || 'mp';
const tokenInfo = await exchangeCode(config, code, authMode);
let nickname = null;
let avatarUrl = null;
let unionid = tokenInfo.unionid;
if (config.scope === 'snsapi_userinfo') {
if (authMode === 'mp' && config.scope === 'snsapi_userinfo') {
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
nickname = profile.nickname;
avatarUrl = profile.avatarUrl;
unionid = profile.unionid ?? unionid;
}
if (authMode === 'open') {
try {
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
nickname = profile.nickname;
avatarUrl = profile.avatarUrl;
unionid = profile.unionid ?? unionid;
} catch {
// snsapi_login may not always return userinfo; proceed with openid only
}
}
if (!userAuth?.loginByWechat) {
if (!userAuth?.resolveWechatAuth) {
throw new Error('用户系统未就绪');
}
const result = await userAuth.loginByWechat({
appId: config.appId,
const result = await userAuth.resolveWechatAuth({
appId: tokenInfo.appId,
openid: tokenInfo.openid,
unionid,
nickname,
avatarUrl,
intent: stateRow.intent || 'login',
bindUserId: stateRow.bind_user_id,
returnTo: stateRow.return_to || '/',
utmSource: stateRow.utm_source,
utmMedium: stateRow.utm_medium,
utmCampaign: stateRow.utm_campaign,
ip,
utmSource: stateRow.utmSource,
utmMedium: stateRow.utmMedium,
utmCampaign: stateRow.utmCampaign,
});
if (!result.ok) {
if (authMode === 'open') {
await markStateComplete(state, {
resultKind: 'error',
resultMessage: result.message || '微信登录失败',
});
return {
authMode,
action: 'poll_error',
message: result.message || '微信登录失败',
};
}
throw new Error(result.message || '微信登录失败');
}
if (result.action === 'binding_gate') {
if (authMode === 'open') {
await markStateComplete(state, {
resultKind: 'binding_gate',
resultToken: result.pendingToken,
});
return {
authMode,
action: 'binding_gate',
pendingToken: result.pendingToken,
wechatProfile: result.wechatProfile,
returnTo: result.returnTo,
};
}
return {
authMode,
action: 'binding_gate',
pendingToken: result.pendingToken,
wechatProfile: result.wechatProfile,
returnTo: result.returnTo,
utmSource: result.utmSource,
utmMedium: result.utmMedium,
utmCampaign: result.utmCampaign,
};
}
if (authMode === 'open') {
await markStateComplete(state, {
resultKind: 'login',
resultToken: result.token,
});
return {
authMode,
action: 'login',
token: result.token,
user: result.user,
isNewUser: result.isNewUser,
returnTo: stateRow.return_to || '/',
utmSource: stateRow.utm_source,
utmMedium: stateRow.utm_medium,
utmCampaign: stateRow.utm_campaign,
};
}
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
return {
authMode,
action: 'login',
token: result.token,
user: result.user,
isNewUser: result.isNewUser,
returnTo: stateRow.returnTo,
utmSource: stateRow.utmSource,
utmMedium: stateRow.utmMedium,
utmCampaign: stateRow.utmCampaign,
bound: result.bound,
returnTo: stateRow.return_to || '/',
utmSource: stateRow.utm_source,
utmMedium: stateRow.utm_medium,
utmCampaign: stateRow.utm_campaign,
};
};
@@ -223,9 +402,16 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
enabled: true,
publicConfig(req) {
const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false;
return { enabled: true, inWechat };
return {
enabled: true,
inWechat,
scanEnabled: Boolean(config.scanEnabled),
appId: config.appId,
};
},
buildAuthorizeRedirect,
startScanLogin,
pollScanLogin,
handleCallback,
};
}