Files
BYW-VoiceHub/server/api/auth/wechat/login.post.ts
T
Henry_Du ebf1c3a127 perf(auth): 登录去掉一次冗余查库 + 缓存 2FA 查询 + 修掉 session_key 进日志
登录实测约 232ms,其中 bcrypt 约 72ms、网络往返约 80ms 是不可省的计算/物理成本,
剩下的都是我们自己的开销。这次把能省的两处省掉(实测约 40ms,占登录耗时约 19%):

1. 去掉「数据库连接检查」的冗余预检
   原来每次登录先 await db.select().from(users).limit(1) 只为"测连接",
   紧接着又会真的查用户,连不上一样会报错 —— 纯粹多一次数据库往返。
   现在把那次真实查询包进 try,只有它失败才回报 503「数据库服务暂时不可用」,
   错误码与文案完全不变。

2. 缓存「这个账号有没有开 2FA」
   登录每次都查 userIdentities 找 TOTP,但绝大多数账号根本没开 2FA。
   新增 server/utils/totp-cache.ts:
     · 查到**没有** TOTP → 缓存 10 分钟,后续登录直接跳过查询
     · 查到**有** TOTP   → **不缓存**(安全优先:绝不能因为缓存而让刚开 2FA 的人被跳过验证)
   开启/关闭 2FA 的接口必须调用 invalidateTotpCache(userId),已在
   server/api/user/2fa/{enable,disable}.post.ts 接上 —— 否则会有最长 10 分钟的验证空窗。
   该缓存不参与密码校验,最坏情况只是多查一次库。

3. lastLogin / lastLoginIp 的 UPDATE 不再阻塞响应
   登录时间与登录 IP 是审计信息,晚写几十毫秒、甚至这次写失败,
   都不影响用户能否登录。改为不 await(保留 .catch,避免 unhandledRejection)。

4. 安全修复:微信登录不再把 session_key 打进日志
   server/api/auth/wechat/login.post.ts 原来打印整个 jscode2session 响应前 500 字,
   而该响应含 { session_key, openid, unionid }。
   session_key 是敏感凭据(可解密用户数据、配合其他接口换取手机号),
   长期落在服务端日志里等于凭据落盘。
   现在只打印:顶层键名、errcode/errmsg、以及 openid/session_key/unionid 的
   存在性与长度(不打印值);仅在"响应根本不是对象"时才打印截断原文
   (那种情况它不可能是合法凭据,且是排查被代理拦成 HTML 的唯一线索)。

已验证:
- 缓存逻辑用假 db 真跑过 5 个场景(命中缓存省下查询、失效后立刻重查、
  有 2FA 不缓存、多用户互不干扰、只失效指定用户)
- 4 个改动文件的相对 import 路径均存在;totp-cache 的导出与两处 import 对得上
- login.post.ts 抽走 TOTP 查询后已清理不再使用的 userIdentities / and 导入
- 文件编码 UTF-8 无 BOM、无乱码、括号平衡
2026-10-01 21:04:02 +08:00

242 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: any
try {
session = await $fetch<any>('https://api.weixin.qq.com/sns/jscode2session', {
query: { appid, secret, js_code: code, grant_type: 'authorization_code' },
timeout: 8000,
/*
* ★★★ 关键修复(线上「微信登录一直 401」的真正根因)★★★
*
* ofetch 按响应的 **content-type** 决定要不要解析 JSON。
* 微信的 jscode2session 返回的 content-type 不是 application/json,
* 于是 ofetch **原样返回字符串**,`session` 就成了 string 而不是对象 ——
* 结果 session.openid / errcode / errmsg **全是 undefined**,
* 代码走到 `if (!session.openid)` 抛 401,日志打印「undefined undefined」,
* 用户看到「微信登录失败:未知错误(undefined)」。
*
* 实测(容器内直接跑 ofetch):
* GOT type=string "{\"errcode\":40029,\"errmsg\":\"invalid code\"}"
*
* 显式声明 responseType: 'json' 后才会解析成对象。
*/
responseType: 'json'
})
} catch (e: any) {
console.error('[WeChatMP] jscode2session 请求失败:', e?.message)
throw createError({ statusCode: 502, message: '微信登录服务暂时不可用,请稍后重试' })
}
// 兜底:万一某天 responseType 仍没生效(或上游改了 content-type),手动解析一次,
// 不要再让「字符串被当对象用」这种问题静默成 undefined。
if (typeof session === 'string') {
try {
session = JSON.parse(session)
} catch {
console.error('[WeChatMP] 响应无法解析为 JSON:', String(session).slice(0, 300))
session = null
}
}
/*
* ⚠️ 诊断日志(这次是踩坑加的)。
*
* 线上曾出现 `[WeChatMP] jscode2session 返回异常: undefined undefined` ——
* errcode / errmsg **双 undefined**。这不是「appid 错了」:
* 微信出错时会返回 { errcode, errmsg },两个都取不到说明**响应体根本不是微信的 JSON**。
* 最可能是 $fetch 拿回了一段非 JSON 文本(被代理/防火墙拦截返回的 HTML、
* 或重定向页),此时 ofetch 解析失败会把原始字符串返回,于是所有字段都是 undefined。
*
* 所以这里把**响应的真实类型和关键字段**打出来,否则下次还是只能看到 undefined。
* appid 打前 6 位后 4 位,用于和微信公众平台里的小程序 AppID 核对(两者必须完全一致,
* 且**不能**填公众号的 AppID —— 那套凭据不通用)。
*
* ★★★ 安全修复(第 97 轮)★★★
* 原来这里把**整个响应 JSON 前 500 字**打进日志:
*
* console.error('[WeChatMP] 诊断: 原始响应=' + JSON.stringify(session)?.slice(0, 500))
*
* 而 jscode2session 的成功响应形如:
* { session_key: '...', openid: '...', unionid: '...' }
*
* **`session_key` 是敏感凭据** —— 有了它可以解密用户数据、配合其他接口换取手机号。
* 它出现在服务端日志里(日志会被备份、被运维翻看、可能进日志平台),
* 等于把用户凭据长期落盘。这**不是性能问题,是安全问题**,必须去掉。
*
* 现在只打印**故障定位真正需要**的东西:
* · 顶层键名(判断"是不是微信的 JSON"足够了,不含值)
* · errcode / errmsg(微信的报错码)
* · openid / session_key / unionid 的**存在性与长度**(不打印值)
* · 响应非对象时,才打印截断后的原始文本(此时它不是合法凭据)
*/
const appidMasked =
appid.length > 10 ? appid.slice(0, 6) + '****' + appid.slice(-4) : appid.slice(0, 3) + '****'
const maskLen = (v: unknown) => (typeof v === 'string' && v ? '有(len=' + v.length + ')' : '无')
console.error(
'[WeChatMP] 诊断: appid=' + appidMasked +
' secretLen=' + String(secret).length +
' 响应类型=' + typeof session +
' 是否数组=' + Array.isArray(session) +
' 顶层键=' + (session && typeof session === 'object' ? Object.keys(session).join(',') : '(非对象)')
)
console.error(
'[WeChatMP] 诊断: openid=' + maskLen(session?.openid) +
' session_key=' + maskLen(session?.session_key) +
' unionid=' + maskLen(session?.unionid) +
' errcode=' + (session?.errcode ?? '(无)') +
' errmsg=' + (session?.errmsg ?? '(无)')
)
// 只有"响应根本不是对象"时才打印原文 —— 那种情况下它不可能是合法凭据,
// 而且是排查"被代理拦成 HTML"唯一有用的信息。
if (!session || typeof session !== 'object') {
console.error(
'[WeChatMP] 诊断: 响应非对象,原文前 300 字=' +
(typeof session === 'string' ? session.slice(0, 300) : String(session))
)
}
// 微信接口失败时仍返回 HTTP 200,必须检查 openid
if (!session || typeof session !== 'object' || !session.openid) {
// 区分「微信明确报错」和「响应体不是微信的 JSON」——两者排查方向完全不同
const notJson = !session || typeof session !== 'object'
const errcode = session && session.errcode
const errmsg = session && session.errmsg
console.error('[WeChatMP] jscode2session 返回异常:', errcode, errmsg, notJson ? '(响应非 JSON)' : '')
const message = notJson
? '微信登录失败:服务器访问微信接口时未收到合法响应(可能被网络策略拦截),请查看服务端日志'
: `微信登录失败:${errmsg || '未知错误(' + (errcode ?? '无错误码') + ')'}`
throw createError({ statusCode: 401, message })
}
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
}
})