From 04ef1c910502ae2889f62e5d4f79f8216b9789cb Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Jul 2026 12:41:37 +0800 Subject: [PATCH] feat(auth): add WeChat mini program login endpoint Add jscode2session exchange, CSRF bypass for servicewechat.com, and openid-based auto register/login for the mini program client. Co-authored-by: Cursor --- .env.example | 4 +++ server.mjs | 54 +++++++++++++++++++++++++++++++++++ user-auth.mjs | 41 +++++++++++++++++++++++++++ wechat-miniapp.mjs | 63 +++++++++++++++++++++++++++++++++++++++++ wechat-miniapp.test.mjs | 59 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 wechat-miniapp.mjs create mode 100644 wechat-miniapp.test.mjs diff --git a/.env.example b/.env.example index 8b858dd..27f31d1 100644 --- a/.env.example +++ b/.env.example @@ -199,6 +199,10 @@ H5_ACCESS_PASSWORD=change-me # H5_WECHAT_OPEN_APP_ID= # H5_WECHAT_OPEN_APP_SECRET= +# 微信小程序原生登录(wx.login → /auth/wechat-miniapp/login) +# H5_WECHAT_MINIAPP_APP_ID=wx... +# H5_WECHAT_MINIAPP_APP_SECRET=小程序AppSecret + # 本地开发:pnpm dev 会自动启动 server.mjs + vite # 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server) # H5_DEV_PORTAL=http://127.0.0.1:8081 diff --git a/server.mjs b/server.mjs index 7b7580c..277a760 100644 --- a/server.mjs +++ b/server.mjs @@ -172,6 +172,7 @@ import { WECHAT_NOTIFY_SUCCESS_V2, } from './wechat-pay.mjs'; import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; +import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { loadWechatMpModule } from './wechat-mp-loader.mjs'; import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; @@ -262,6 +263,15 @@ app.use((req, res, next) => { next(); }); +function isWechatMiniProgramSource(value) { + if (!value) return false; + try { + return new URL(value).hostname.endsWith('servicewechat.com'); + } catch { + return false; + } +} + function csrfOriginCheck(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const host = req.get('host'); @@ -269,6 +279,9 @@ function csrfOriginCheck(req, res, next) { const referer = req.get('referer'); if (!origin && !referer) return next(); const requestHostname = (host ?? '').split(':')[0]; + if ([origin, referer].some(isWechatMiniProgramSource)) { + return next(); + } const allowed = [origin, referer].some((value) => { if (!value) return false; try { @@ -904,6 +917,47 @@ app.post('/auth/login', jsonBody, async (req, res) => { return res.json({ authenticated: true, mode: 'legacy' }); }); +app.post('/auth/wechat-miniapp/login', jsonBody, async (req, res) => { + await userAuthReady; + if (!userAuth) { + return res.status(503).json({ message: '未启用用户系统' }); + } + const miniappConfig = loadWechatMiniappConfig(); + if (!miniappConfig.enabled) { + return res.status(503).json({ message: '小程序登录未配置,请联系管理员' }); + } + const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''; + if (!code) { + return res.status(400).json({ message: '缺少微信登录 code' }); + } + try { + const session = await exchangeMiniProgramCode({ + appId: miniappConfig.appId, + appSecret: miniappConfig.appSecret, + code, + }); + const result = await userAuth.loginByWechatMiniProgram({ + appId: miniappConfig.appId, + openid: session.openid, + unionid: session.unionid, + }); + if (!result.ok) { + return res.status(401).json({ message: result.message || '微信登录失败' }); + } + setUserLoginCookies(res, req, result.token); + return res.json({ + authenticated: true, + user: result.user, + mode: 'user', + isNewUser: Boolean(result.isNewUser), + }); + } catch (err) { + const message = err instanceof Error ? err.message : '微信登录失败'; + const status = err?.code === 'wechat_miniapp_code_failed' ? 401 : 400; + return res.status(status).json({ message }); + } +}); + app.post('/auth/register', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { diff --git a/user-auth.mjs b/user-auth.mjs index 92d3a22..f6a9595 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -2865,11 +2865,52 @@ export function createUserAuth(pool, options = {}) { return result; }; + const loginByWechatMiniProgram = async ({ + appId, + openid, + unionid, + now = Date.now(), + }) => { + const existing = await findWechatUserByOpenid(appId, openid); + if (existing) { + if (existing.status === 'disabled') { + return { ok: false, message: '账户已禁用,请联系管理员' }; + } + return loginBoundWechatUser({ + userId: existing.userId, + appId, + openid, + unionid, + nickname: existing.nickname, + avatarUrl: null, + now, + }); + } + + const registered = await registerViaWechat({ + appId, + openid, + unionid, + nickname: '微信用户', + avatarUrl: null, + now, + }); + if (!registered.ok) return registered; + const token = await issueUserSession(registered.user.id, registered.user.role, now); + return { + ok: true, + token, + user: registered.user, + isNewUser: true, + }; + }; + return { USER_COOKIE, register, login, loginByWechat, + loginByWechatMiniProgram, resolveWechatAuth, completeWechatRegister, completeWechatBindAccount, diff --git a/wechat-miniapp.mjs b/wechat-miniapp.mjs new file mode 100644 index 0000000..1c1077e --- /dev/null +++ b/wechat-miniapp.mjs @@ -0,0 +1,63 @@ +import { fetch } from 'undici'; + +const JSCODE2SESSION_URL = 'https://api.weixin.qq.com/sns/jscode2session'; + +export function loadWechatMiniappConfig(env = process.env) { + const appId = + env.H5_WECHAT_MINIAPP_APP_ID?.trim() ?? + env.H5_WECHAT_APP_ID?.trim() ?? + ''; + const appSecret = + env.H5_WECHAT_MINIAPP_APP_SECRET?.trim() ?? + env.H5_WECHAT_APP_SECRET?.trim() ?? + ''; + return { + enabled: Boolean(appId && appSecret), + appId, + appSecret, + }; +} + +export async function exchangeMiniProgramCode( + { appId, appSecret, code, fetchImpl = fetch } = {}, +) { + const jsCode = String(code ?? '').trim(); + if (!appId || !appSecret) { + throw Object.assign(new Error('小程序登录未配置 AppID/AppSecret'), { + code: 'wechat_miniapp_not_configured', + }); + } + if (!jsCode) { + throw Object.assign(new Error('缺少微信登录 code'), { code: 'invalid_code' }); + } + + const url = new URL(JSCODE2SESSION_URL); + url.searchParams.set('appid', appId); + url.searchParams.set('secret', appSecret); + url.searchParams.set('js_code', jsCode); + url.searchParams.set('grant_type', 'authorization_code'); + + const response = await fetchImpl(url); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw Object.assign(new Error('微信登录服务暂不可用'), { + code: 'wechat_miniapp_http_failed', + status: response.status, + }); + } + if (payload.errcode) { + throw Object.assign(new Error(payload.errmsg || '微信 code 无效或已过期'), { + code: 'wechat_miniapp_code_failed', + errcode: payload.errcode, + }); + } + const openid = String(payload.openid ?? '').trim(); + if (!openid) { + throw Object.assign(new Error('微信未返回 openid'), { code: 'wechat_miniapp_openid_missing' }); + } + return { + openid, + sessionKey: payload.session_key ?? null, + unionid: payload.unionid ? String(payload.unionid).trim() : null, + }; +} diff --git a/wechat-miniapp.test.mjs b/wechat-miniapp.test.mjs new file mode 100644 index 0000000..7260b07 --- /dev/null +++ b/wechat-miniapp.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs'; + +test('loadWechatMiniappConfig prefers miniapp-specific env vars', () => { + const config = loadWechatMiniappConfig({ + H5_WECHAT_MINIAPP_APP_ID: 'wx-mini', + H5_WECHAT_MINIAPP_APP_SECRET: 'secret-mini', + H5_WECHAT_APP_ID: 'wx-other', + H5_WECHAT_APP_SECRET: 'secret-other', + }); + assert.equal(config.enabled, true); + assert.equal(config.appId, 'wx-mini'); + assert.equal(config.appSecret, 'secret-mini'); +}); + +test('exchangeMiniProgramCode calls jscode2session and returns openid', async () => { + const calls = []; + const result = await exchangeMiniProgramCode({ + appId: 'wxtest', + appSecret: 'secret', + code: 'abc123', + fetchImpl: async (url) => { + calls.push(String(url)); + return { + ok: true, + async json() { + return { openid: 'openid-1', session_key: 'sk', unionid: 'union-1' }; + }, + }; + }, + }); + assert.equal(result.openid, 'openid-1'); + assert.equal(result.unionid, 'union-1'); + assert.match(calls[0], /jscode2session\?/); + assert.match(calls[0], /js_code=abc123/); +}); + +test('exchangeMiniProgramCode surfaces WeChat errcode', async () => { + await assert.rejects( + () => + exchangeMiniProgramCode({ + appId: 'wxtest', + appSecret: 'secret', + code: 'bad', + fetchImpl: async () => ({ + ok: true, + async json() { + return { errcode: 40029, errmsg: 'invalid code' }; + }, + }), + }), + (error) => { + assert.equal(error.code, 'wechat_miniapp_code_failed'); + assert.match(error.message, /invalid code/); + return true; + }, + ); +});