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_ID=
|
||||||
# H5_WECHAT_OPEN_APP_SECRET=
|
# 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 会自动启动 server.mjs + vite
|
||||||
# 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server)
|
# 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server)
|
||||||
# H5_DEV_PORTAL=http://127.0.0.1:8081
|
# H5_DEV_PORTAL=http://127.0.0.1:8081
|
||||||
|
|||||||
+54
@@ -172,6 +172,7 @@ import {
|
|||||||
WECHAT_NOTIFY_SUCCESS_V2,
|
WECHAT_NOTIFY_SUCCESS_V2,
|
||||||
} from './wechat-pay.mjs';
|
} from './wechat-pay.mjs';
|
||||||
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
||||||
|
import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs';
|
||||||
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||||
import { loadWechatMpModule } from './wechat-mp-loader.mjs';
|
import { loadWechatMpModule } from './wechat-mp-loader.mjs';
|
||||||
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
||||||
@@ -262,6 +263,15 @@ app.use((req, res, next) => {
|
|||||||
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) {
|
function csrfOriginCheck(req, res, next) {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||||||
const host = req.get('host');
|
const host = req.get('host');
|
||||||
@@ -269,6 +279,9 @@ function csrfOriginCheck(req, res, next) {
|
|||||||
const referer = req.get('referer');
|
const referer = req.get('referer');
|
||||||
if (!origin && !referer) return next();
|
if (!origin && !referer) return next();
|
||||||
const requestHostname = (host ?? '').split(':')[0];
|
const requestHostname = (host ?? '').split(':')[0];
|
||||||
|
if ([origin, referer].some(isWechatMiniProgramSource)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
const allowed = [origin, referer].some((value) => {
|
const allowed = [origin, referer].some((value) => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
@@ -904,6 +917,47 @@ app.post('/auth/login', jsonBody, async (req, res) => {
|
|||||||
return res.json({ authenticated: true, mode: 'legacy' });
|
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) => {
|
app.post('/auth/register', jsonBody, async (req, res) => {
|
||||||
await userAuthReady;
|
await userAuthReady;
|
||||||
if (!userAuth) {
|
if (!userAuth) {
|
||||||
|
|||||||
@@ -2865,11 +2865,52 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
return result;
|
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 {
|
return {
|
||||||
USER_COOKIE,
|
USER_COOKIE,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
loginByWechat,
|
loginByWechat,
|
||||||
|
loginByWechatMiniProgram,
|
||||||
resolveWechatAuth,
|
resolveWechatAuth,
|
||||||
completeWechatRegister,
|
completeWechatRegister,
|
||||||
completeWechatBindAccount,
|
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