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
This commit is contained in:
@@ -114,3 +114,10 @@ AGGREGATE_OAUTH_APP_KEY=
|
|||||||
AGGREGATE_OAUTH_LOGIN_TYPE=wx
|
AGGREGATE_OAUTH_LOGIN_TYPE=wx
|
||||||
# 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn
|
# 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn
|
||||||
AGGREGATE_OAUTH_ENDPOINT=
|
AGGREGATE_OAUTH_ENDPOINT=
|
||||||
|
|
||||||
|
# 微信小程序登录(可选)
|
||||||
|
# 用于小程序端 wx.login() 的 code 换取 openid,对应接口 POST /api/auth/wechat/login
|
||||||
|
# AppID / AppSecret 在微信公众平台「开发管理 → 开发设置 → 开发者ID」中获取
|
||||||
|
# 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用
|
||||||
|
WECHAT_MINI_APPID=
|
||||||
|
WECHAT_MINI_SECRET=
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { getClientIP } from '~~/server/utils/ip-utils'
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { username, password } = body
|
const { username, password } = body
|
||||||
const bindingToken = getCookie(event, 'binding-token')
|
// 网页端走 cookie;小程序等非浏览器客户端不便维护 cookie,允许从请求体传入
|
||||||
|
const bindingToken = getCookie(event, 'binding-token') || body.bindToken
|
||||||
|
|
||||||
if (!bindingToken) {
|
if (!bindingToken) {
|
||||||
throw createError({ statusCode: 400, message: '绑定会话已过期,请重新通过第三方登录发起' })
|
throw createError({ statusCode: 400, message: '绑定会话已过期,请重新通过第三方登录发起' })
|
||||||
@@ -105,5 +106,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
// 清除绑定令牌
|
// 清除绑定令牌
|
||||||
deleteCookie(event, 'binding-token')
|
deleteCookie(event, 'binding-token')
|
||||||
|
|
||||||
return { success: true }
|
return {
|
||||||
|
success: true,
|
||||||
|
// 供小程序等非浏览器客户端使用:后续请求带 Authorization: Bearer <token>
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
grade: user.grade,
|
||||||
|
class: user.class,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -25,6 +25,8 @@ export const PUBLIC_API_EXACT_ROUTES: readonly RoutePolicy[] = [
|
|||||||
{ path: '/api/auth/grade-class-options', methods: GET_METHODS },
|
{ path: '/api/auth/grade-class-options', methods: GET_METHODS },
|
||||||
{ path: '/api/auth/oauth-register-options', methods: GET_METHODS },
|
{ path: '/api/auth/oauth-register-options', methods: GET_METHODS },
|
||||||
{ path: '/api/auth/bind', methods: ['POST'] },
|
{ 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/oauth-register', methods: ['POST'] },
|
||||||
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
|
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
|
||||||
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
|
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user