Files
BYW-VoiceHub/server/api/auth/wechat/login.post.ts
T
Henry_Du 12dac95e1b feat(auth): 新增微信小程序登录(绑定策略:首次用学号密码绑定)
- 新增 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
2026-09-26 13:57:24 +08:00

155 lines
4.7 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: Jscode2SessionResult
try {
session = await $fetch<Jscode2SessionResult>(
'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
}
})