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:
+223
-37
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user