From 12dac95e1b3af9a87c9b9aa1e5b3520a8852905b Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sat, 26 Sep 2026 13:57:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=96=B0=E5=A2=9E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=99=BB=E5=BD=95=EF=BC=88?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E7=AD=96=E7=95=A5=EF=BC=9A=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E7=94=A8=E5=AD=A6=E5=8F=B7=E5=AF=86=E7=A0=81=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/auth/wechat/login:用 wx.login 的 code 换 openid;已绑定直接签发 JWT 返回,未绑定返回 bindToken - bind.post.ts:支持从请求体读取 bindToken(小程序不便维护 cookie),绑定成功后返回 token,供 Authorization: Bearer 使用 - auth-route-policy.ts:把新接口登记进免鉴权白名单,否则会被鉴权中间件拦截 - .env.example:新增 WECHAT_MINI_APPID / WECHAT_MINI_SECRET --- .env.example | 7 ++ server/api/auth/bind.post.ts | 17 ++- server/api/auth/wechat/login.post.ts | 154 +++++++++++++++++++++++++++ server/utils/auth-route-policy.ts | 2 + 4 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 server/api/auth/wechat/login.post.ts diff --git a/.env.example b/.env.example index 2709684..d471272 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,10 @@ AGGREGATE_OAUTH_APP_KEY= AGGREGATE_OAUTH_LOGIN_TYPE=wx # 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn AGGREGATE_OAUTH_ENDPOINT= + +# 微信小程序登录(可选) +# 用于小程序端 wx.login() 的 code 换取 openid,对应接口 POST /api/auth/wechat/login +# AppID / AppSecret 在微信公众平台「开发管理 → 开发设置 → 开发者ID」中获取 +# 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用 +WECHAT_MINI_APPID= +WECHAT_MINI_SECRET= diff --git a/server/api/auth/bind.post.ts b/server/api/auth/bind.post.ts index 43025d1..c9e9a2b 100644 --- a/server/api/auth/bind.post.ts +++ b/server/api/auth/bind.post.ts @@ -13,7 +13,8 @@ import { getClientIP } from '~~/server/utils/ip-utils' export default defineEventHandler(async (event) => { const body = await readBody(event) const { username, password } = body - const bindingToken = getCookie(event, 'binding-token') + // 网页端走 cookie;小程序等非浏览器客户端不便维护 cookie,允许从请求体传入 + const bindingToken = getCookie(event, 'binding-token') || body.bindToken if (!bindingToken) { throw createError({ statusCode: 400, message: '绑定会话已过期,请重新通过第三方登录发起' }) @@ -105,5 +106,17 @@ export default defineEventHandler(async (event) => { // 清除绑定令牌 deleteCookie(event, 'binding-token') - return { success: true } + return { + success: true, + // 供小程序等非浏览器客户端使用:后续请求带 Authorization: Bearer + token, + user: { + id: user.id, + username: user.username, + name: user.name, + grade: user.grade, + class: user.class, + role: user.role + } + } }) diff --git a/server/api/auth/wechat/login.post.ts b/server/api/auth/wechat/login.post.ts new file mode 100644 index 0000000..8bb82d5 --- /dev/null +++ b/server/api/auth/wechat/login.post.ts @@ -0,0 +1,154 @@ +import { db } from '~/drizzle/db' +import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' +import { createAuthSession } from '~~/server/utils/create-auth-session' +import { generateBindingToken } from '~~/server/utils/oauth-token' +import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService' + +/** + * 微信小程序登录 + * + * 与网页端的聚合 OAuth(provider = 'wx')区分开:小程序拿到的 openid + * 是按小程序 AppID 维度生成的,与公众号网页授权的 openid 不通用, + * 因此单独使用 provider = 'wechat-mp' 存储身份。 + * + * 请求体:{ code } —— code 来自小程序端 wx.login() + * 响应: + * - 已绑定:{ success: true, needBind: false, token, user } + * - 未绑定:{ success: false, needBind: true, bindToken } + * 客户端接着调用 /api/auth/bind(把 bindToken 放进请求体)完成绑定 + */ + +const PROVIDER = 'wechat-mp' + +interface Jscode2SessionResult { + openid?: string + session_key?: string + unionid?: string + errcode?: number + errmsg?: string +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const code = body?.code + + if (!code || typeof code !== 'string') { + throw createError({ statusCode: 400, message: '缺少 code 参数' }) + } + + const appid = process.env.WECHAT_MINI_APPID + const secret = process.env.WECHAT_MINI_SECRET + if (!appid || !secret) { + console.error('[WeChatMP] WECHAT_MINI_APPID / WECHAT_MINI_SECRET 未配置,无法登录') + throw createError({ statusCode: 500, message: '服务器未配置小程序凭据' }) + } + + // 1. 用 code 换取 openid + let session: Jscode2SessionResult + try { + session = await $fetch( + 'https://api.weixin.qq.com/sns/jscode2session', + { + query: { appid, secret, js_code: code, grant_type: 'authorization_code' }, + timeout: 8000 + } + ) + } catch (e: any) { + console.error('[WeChatMP] jscode2session 请求失败:', e?.message) + throw createError({ statusCode: 502, message: '微信登录服务暂时不可用,请稍后重试' }) + } + + // 微信接口失败时仍返回 HTTP 200,必须检查 openid + if (!session.openid) { + console.error('[WeChatMP] jscode2session 返回异常:', session.errcode, session.errmsg) + throw createError({ + statusCode: 401, + message: `微信登录失败:${session.errmsg || '未知错误(' + session.errcode + ')'}` + }) + } + + const openid = session.openid + + // 2. 查询该 openid 是否已绑定到某个账号 + const existingIdentity = await db.query.userIdentities.findFirst({ + where: (t, { eq, and }) => and(eq(t.provider, PROVIDER), eq(t.providerUserId, openid)), + with: { user: true } + }) + + const isSecure = + getRequestURL(event).protocol === 'https:' || + getRequestHeader(event, 'x-forwarded-proto') === 'https' + + // 3a. 已绑定 —— 直接登录 + if (existingIdentity && existingIdentity.user) { + const user = existingIdentity.user + + if (user.status === 'withdrawn') { + throw createError({ statusCode: 403, message: '该账号已注销' }) + } + if (user.status === 'graduate') { + throw createError({ statusCode: 403, message: '该账号已毕业,限制访问' }) + } + if (isUserBlocked(user.id)) { + const remaining = getUserBlockRemainingTime(user.id) + throw createError({ + statusCode: 423, + message: `账户处于风险控制期,请在 ${remaining} 分钟后重试` + }) + } + + const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role) + await createAuthSession(event, { + userId: user.id, + jti, + loginMethod: 'oauth', + oauthProvider: PROVIDER + }) + + setCookie(event, 'auth-token', token, { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7天 + path: '/' + }) + + return { + success: true, + needBind: false, + token, + user: { + id: user.id, + username: user.username, + name: user.name, + grade: user.grade, + class: user.class, + role: user.role, + needsPasswordChange: !user.passwordChangedAt + } + } + } + + // 3b. 未绑定 —— 签发绑定令牌,由客户端带着学号密码调用 /api/auth/bind + const bindingToken = generateBindingToken({ + provider: PROVIDER, + providerUserId: openid, + providerUsername: '微信用户' + }) + + // 同时写入 cookie,保证网页端流程也能复用同一套绑定逻辑 + setCookie(event, 'binding-token', bindingToken, { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: 60 * 10, // 10分钟 + path: '/' + }) + + return { + success: false, + needBind: true, + provider: PROVIDER, + bindToken: bindingToken + } +}) diff --git a/server/utils/auth-route-policy.ts b/server/utils/auth-route-policy.ts index ff2ab06..58bc206 100644 --- a/server/utils/auth-route-policy.ts +++ b/server/utils/auth-route-policy.ts @@ -25,6 +25,8 @@ export const PUBLIC_API_EXACT_ROUTES: readonly RoutePolicy[] = [ { path: '/api/auth/grade-class-options', methods: GET_METHODS }, { path: '/api/auth/oauth-register-options', methods: GET_METHODS }, { path: '/api/auth/bind', methods: ['POST'] }, + // 微信小程序登录(wx.login 的 code 换 openid);未绑定时返回 bindToken,供上面的 /api/auth/bind 使用 + { path: '/api/auth/wechat/login', methods: ['POST'] }, { path: '/api/auth/oauth-register', methods: ['POST'] }, { path: '/api/auth/2fa/verify', methods: ['POST'] }, { path: '/api/auth/2fa/send-email', methods: ['POST'] },