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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-11 12:41:37 +08:00
parent fe6d631ba1
commit 04ef1c9105
5 changed files with 221 additions and 0 deletions
+4
View File
@@ -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
+54
View File
@@ -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) {
+41
View File
@@ -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,
+63
View File
@@ -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,
};
}
+59
View File
@@ -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;
},
);
});