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', 'scan']); function isPollScanAuthMode(authMode) { return authMode === 'open' || authMode === 'scan'; } function resolveCallbackUrl(env = process.env) { const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? ''; if (explicit) return explicit; const base = env.H5_PUBLIC_BASE_URL?.trim() ?? ''; if (base) return `${base.replace(/\/$/, '')}/auth/wechat/callback`; return ''; } export function isWechatUserAgent(userAgent = '') { const ua = userAgent || ''; return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua); } 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, }; } export function sanitizeWechatReturnTo(returnTo, req) { if (!returnTo || typeof returnTo !== 'string') return '/'; const host = req.get('host'); try { const url = new URL(returnTo, `http://${host}`); if (url.host !== host) return '/'; if (!url.pathname.startsWith('/')) return '/'; return `${url.pathname}${url.search}${url.hash}`; } catch { if (returnTo.startsWith('/') && !returnTo.startsWith('//')) return returnTo; return '/'; } } function normalizeIntent(intent) { const value = typeof intent === 'string' ? intent.trim().toLowerCase() : 'login'; return VALID_INTENTS.has(value) ? value : 'login'; } 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 useMp = authMode === 'mp' || authMode === 'scan'; const params = new URLSearchParams({ 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', }); const res = await fetch(`${OAUTH_ACCESS_TOKEN_URL}?${params.toString()}`); const data = await res.json(); if (data.errcode) { throw new Error(data.errmsg || `微信授权失败 (${data.errcode})`); } if (!data.openid) { throw new Error('微信授权未返回 openid'); } return { accessToken: data.access_token, openid: data.openid, unionid: data.unionid ?? null, refreshToken: data.refresh_token ?? null, appId: isOpen ? config.openAppId : config.appId, }; } async function fetchUserInfo(accessToken, openid) { const params = new URLSearchParams({ access_token: accessToken, openid, lang: 'zh_CN', }); const res = await fetch(`${OAUTH_USERINFO_URL}?${params.toString()}`); const data = await res.json(); if (data.errcode) { throw new Error(data.errmsg || `获取微信用户信息失败 (${data.errcode})`); } return { nickname: typeof data.nickname === 'string' ? data.nickname.slice(0, 128) : null, avatarUrl: typeof data.headimgurl === 'string' ? data.headimgurl.slice(0, 512) : null, unionid: data.unionid ?? null, }; } export function createWechatOAuthService(pool, config, { userAuth } = {}) { if (!config?.enabled) { return { enabled: false, publicConfig() { return { enabled: false, inWechat: false, scanEnabled: false }; }, }; } const pruneExpiredStates = async (now = Date.now()) => { if (!pool) return; await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]); }; const createState = async ({ returnTo = '/', utmSource = null, utmMedium = null, utmCampaign = null, intent = 'login', bindUserId = null, authMode = 'mp', now = Date.now(), } = {}) => { const state = crypto.randomBytes(24).toString('base64url'); if (pool) { await pruneExpiredStates(now); await pool.query( `INSERT INTO h5_wechat_oauth_states (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 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, 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]; 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 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) => { const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req); const utmSource = typeof req.query?.utm_source === 'string' ? req.query.utm_source : null; const utmMedium = typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null; const utmCampaign = typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null; const common = { returnTo, utmSource, utmMedium, utmCampaign, intent: 'login', }; if (config.scanEnabled) { const state = await createState({ ...common, authMode: 'open' }); return { mode: 'open', state, openAppId: config.openAppId, redirectUri: config.callbackUrl, expiresInMs: STATE_TTL_MS, }; } const state = await createState({ ...common, authMode: 'scan' }); return { mode: 'mp', state, qrUrl: buildAuthorizeUrl(config, { state, authMode: 'scan' }), 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 loadState(state); if (!stateRow) { throw new Error('授权状态无效或已过期,请重新登录'); } const authMode = stateRow.auth_mode || 'mp'; const tokenInfo = await exchangeCode(config, code, authMode); let nickname = null; let avatarUrl = null; let unionid = tokenInfo.unionid; if ((authMode === 'mp' || authMode === 'scan') && 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?.resolveWechatAuth) { throw new Error('用户系统未就绪'); } 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, }); if (!result.ok) { if (isPollScanAuthMode(authMode)) { 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 (isPollScanAuthMode(authMode)) { 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 (isPollScanAuthMode(authMode)) { 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, bound: result.bound, returnTo: stateRow.return_to || '/', utmSource: stateRow.utm_source, utmMedium: stateRow.utm_medium, utmCampaign: stateRow.utm_campaign, }; }; return { enabled: true, publicConfig(req) { const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false; return { enabled: true, inWechat, scanEnabled: true, openScanEnabled: Boolean(config.scanEnabled), appId: config.appId, openAppId: config.scanEnabled ? config.openAppId : undefined, oauthCallbackUrl: config.scanEnabled ? config.callbackUrl : undefined, }; }, buildAuthorizeRedirect, startScanLogin, pollScanLogin, handleCallback, }; }