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