feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { db, users, eq } from '~/drizzle/db'
import { SmtpService } from '~~/server/services/smtpService'
import { getClientIP } from '~~/server/utils/ip-utils'
import {
delStoreIfValue,
getStore,
hashStateCode,
parseStoreJson,
setStore
} from '~~/server/utils/captchaStore'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { randomInt } from 'crypto'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, unknown> | null>(event)
const reqUserId = body?.userId
const rawToken = typeof body?.token === 'string' ? body.token : ''
const email = typeof body?.email === 'string' ? body.email : ''
const token = rawToken || getCookie(event, 'pre-auth-token')
// 必须提供预认证令牌
if (!token) {
throw createApiError(400, 'AUTH_MISSING_PRE_TOKEN', '缺少预认证令牌')
}
let userId: number
let preAuthPayload: { tokenVersion?: unknown } | null = null
try {
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
throw new Error('无效的预认证令牌')
}
userId = decoded.userId
preAuthPayload = decoded
} catch (e) {
deleteCookie(event, 'pre-auth-token')
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
}
// 校验 userId 是否匹配
if (!reqUserId || Number(reqUserId) !== userId) {
throw createApiError(403, 'AUTH_USER_ID_MISMATCH', '非法操作:用户ID不匹配')
}
// 获取用户邮箱
const userResult = await db
.select({
id: users.id,
email: users.email,
emailVerified: users.emailVerified,
name: users.name,
tokenVersion: users.tokenVersion
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
const user = userResult[0]
// 增加 emailVerified 校验
if (!user || !user.email || !user.emailVerified) {
throw createApiError(400, 'AUTH_USER_NOT_FOUND_OR_NO_EMAIL', '用户不存在或未绑定邮箱')
}
if (!JWTEnhanced.hasCurrentTokenVersion(preAuthPayload, user.tokenVersion)) {
deleteCookie(event, 'pre-auth-token')
deleteCookie(event, 'binding-token')
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
}
// 校验用户输入的邮箱是否匹配
if (!email || email.trim().toLowerCase() !== user.email.toLowerCase()) {
throw createApiError(400, 'AUTH_EMAIL_MISMATCH', '邮箱地址不匹配')
}
// 检查是否在冷却时间内
const stateKey = `2fa-email:${userId}`
const existingRaw = await getStore(stateKey)
const existingCode = parseStoreJson<{ expiresAt: number }>(existingRaw)
const now = getServerTimestamp()
if (existingCode && Number.isFinite(existingCode.expiresAt) && existingCode.expiresAt > now) {
// 5 * 60 * 1000 = 300000ms
const totalDuration = 5 * 60 * 1000
const timePassed = totalDuration - (existingCode.expiresAt - now)
if (timePassed < 60 * 1000) {
// 60秒冷却
const remainingSeconds = Math.ceil((60000 - timePassed) / 1000)
throw createApiError(
429,
'AUTH_RATE_LIMITED_SECONDS',
`操作过于频繁,请等待 ${remainingSeconds} 秒后再试`,
{ params: [remainingSeconds] }
)
}
}
const code = randomInt(100000, 1000000).toString()
const expiresAt = now + 5 * 60 * 1000
const storedValue = JSON.stringify({
codeHash: hashStateCode(stateKey, code),
expiresAt
})
await setStore(stateKey, storedValue, 5 * 60)
const clientIP = getClientIP(event)
// 发送邮件
const smtp = SmtpService.getInstance()
await smtp.initializeSmtpConfig()
let sent = false
try {
sent = await smtp.renderAndSend(
user.email,
'verification.code',
{
name: user.name || '用户',
email: user.email,
code,
expiresInMinutes: 5,
action: '登录验证'
},
clientIP
)
} catch (error) {
await delStoreIfValue(stateKey, storedValue)
throw error
}
if (!sent) {
await delStoreIfValue(stateKey, storedValue)
throw createApiError(500, 'AUTH_CODE_SEND_FAILED', '验证码发送失败')
}
return { success: true, message: '验证码已发送至您的邮箱' }
})
+292
View File
@@ -0,0 +1,292 @@
import { db, userIdentities, eq, and, users, systemSettings } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/auth-session'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { getBeijingTime } from '~/utils/timeUtils'
import { verifyBindingToken } from '~~/server/utils/oauth-token'
import { isSecureRequest } from '~~/server/utils/request-utils'
import {
delStore,
delStoreIfValue,
getStore,
incrStore,
parseStoreJson,
verifyStateCode
} from '~~/server/utils/captchaStore'
import otplib from 'otplib'
import { createApiError } from '~~/server/utils/apiError'
import {
computeRequirePasswordChange,
resolveRequirePasswordChange
} from '~~/server/utils/system-settings-helper'
import { getPasswordSetupState } from '~~/server/utils/initial-password-policy'
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
const { authenticator } = otplib
const TOTP_FAILURE_LIMIT = 5
const TOTP_FAILURE_WINDOW_SECONDS = 5 * 60
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, unknown> | null>(event)
const code = typeof body?.code === 'string' ? body.code : ''
const type = typeof body?.type === 'string' ? body.type : ''
const bodyToken = typeof body?.token === 'string' ? body.token : ''
const token = bodyToken || getCookie(event, 'pre-auth-token')
if (!code || !type) {
throw createApiError(400, 'AUTH_MISSING_REQUIRED_PARAMS', '缺少必要参数')
}
// 验证预认证临时令牌
let targetUserId: number
let preAuthPayload: { tokenVersion?: unknown } | null = null
if (token) {
try {
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
throw new Error('无效的预认证令牌')
}
targetUserId = decoded.userId
preAuthPayload = decoded
} catch (e) {
deleteCookie(event, 'pre-auth-token')
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
}
} else {
// 强制要求 Token
throw createApiError(400, 'AUTH_MISSING_PRE_TOKEN_RELOGIN', '缺少预认证令牌,请重新登录')
}
// 获取用户信息
const userResult = await db.select().from(users).where(eq(users.id, targetUserId)).limit(1)
const user = userResult[0]
if (!user) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
if (!JWTEnhanced.hasCurrentTokenVersion(preAuthPayload, user.tokenVersion)) {
deleteCookie(event, 'pre-auth-token')
deleteCookie(event, 'binding-token')
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
}
if (user.status !== 'active') {
throw createApiError(403, 'AUTH_ACCOUNT_DISABLED_OR_RESTRICTED', '账号已被禁用或限制访问')
}
const clientIp = getClientIP(event)
const totpUserFailureKey = `2fa_totp_user:${targetUserId}`
const totpIpFailureKey = `2fa_totp_ip:${clientIp}`
if (type === 'totp') {
const identity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, targetUserId), eq(userIdentities.provider, 'totp'))
})
if (!identity) {
throw createApiError(400, 'AUTH_TOTP_NOT_ENABLED', '未开启TOTP验证')
}
// 先原子占用一次验证额度,防止并发请求同时绕过失败次数限制。
const [userFailureCount, ipFailureCount] = await Promise.all([
incrStore(totpUserFailureKey, TOTP_FAILURE_WINDOW_SECONDS),
incrStore(totpIpFailureKey, TOTP_FAILURE_WINDOW_SECONDS)
])
if (userFailureCount > TOTP_FAILURE_LIMIT || ipFailureCount > TOTP_FAILURE_LIMIT) {
throw createApiError(
429,
'AUTH_TOTP_TOO_MANY_ATTEMPTS',
'动态验证码错误次数过多,请在 5 分钟后重试'
)
}
const totpValid = authenticator.check(code, identity.providerUserId)
if (!totpValid) {
throw createApiError(400, 'AUTH_TOTP_CODE_INVALID', '动态验证码错误')
}
await delStore(totpUserFailureKey)
await delStore(totpIpFailureKey)
} else if (type === 'email') {
const stateKey = `2fa-email:${targetUserId}`
const storedRaw = await getStore(stateKey)
const stored = parseStoreJson<{
codeHash: string
expiresAt: number
}>(storedRaw)
if (!stored || typeof stored.codeHash !== 'string' || !Number.isFinite(stored.expiresAt)) {
if (storedRaw) await delStoreIfValue(stateKey, storedRaw)
throw createApiError(400, 'AUTH_CODE_EXPIRED_OR_MISSING', '验证码已过期或不存在')
}
const now = getServerTimestamp()
if (stored.expiresAt <= now) {
await delStoreIfValue(stateKey, storedRaw!)
throw createApiError(400, 'AUTH_CODE_EXPIRED', '验证码已过期')
}
const remainingTtl = Math.max(1, Math.ceil((stored.expiresAt - now) / 1000))
const attemptKey = `${stateKey}:attempts:${stored.codeHash}`
const attemptCount = await incrStore(attemptKey, remainingTtl)
if (attemptCount > 5) {
await delStoreIfValue(stateKey, storedRaw!)
await delStore(attemptKey)
throw createApiError(400, 'AUTH_TOO_MANY_VERIFY_ATTEMPTS', '验证尝试次数过多,请重新获取')
}
if (verifyStateCode(stateKey, String(code), stored.codeHash)) {
if (!(await delStoreIfValue(stateKey, storedRaw!))) {
throw createApiError(400, 'AUTH_CODE_ALREADY_USED', '验证码已使用,请重新获取')
}
await delStore(attemptKey)
} else {
if (attemptCount >= 5) {
await delStoreIfValue(stateKey, storedRaw!)
await delStore(attemptKey)
throw createApiError(400, 'AUTH_TOO_MANY_VERIFY_ATTEMPTS', '验证尝试次数过多,请重新获取')
}
throw createApiError(
400,
'AUTH_CODE_WRONG_ATTEMPTS_LEFT',
`验证码错误,剩余尝试次数:${5 - attemptCount}`,
{ params: [5 - attemptCount] }
)
}
} else {
throw createApiError(400, 'AUTH_UNSUPPORTED_VERIFICATION_TYPE', '不支持的验证类型')
}
// 验证通过,更新登录信息
const bindingToken = getCookie(event, 'binding-token')
if (bindingToken) {
let bindingPayload
try {
bindingPayload = verifyBindingToken(bindingToken)
} catch (e) {
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
throw createApiError(400, 'AUTH_BINDING_SESSION_INVALID', '绑定会话已失效,请重新发起绑定')
}
await db.transaction(async (tx) => {
const [currentUser] = await tx
.select({
id: users.id,
tokenVersion: users.tokenVersion,
forcePasswordChange: users.forcePasswordChange,
passwordChangedAt: users.passwordChangedAt,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId
})
.from(users)
.where(eq(users.id, user.id))
.for('update')
if (
!currentUser ||
!JWTEnhanced.hasCurrentTokenVersion(preAuthPayload, currentUser.tokenVersion)
) {
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
}
const [currentSettings] = await tx
.select({
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
})
.from(systemSettings)
.limit(1)
.for('share')
const requirePasswordChange = computeRequirePasswordChange(
currentUser,
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
)
if (!canBindOAuthIdentity(requirePasswordChange)) {
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
throw createApiError(
403,
'AUTH_PASSWORD_CHANGE_REQUIRED',
'请先完成密码修改后再绑定第三方账号',
{ requirePasswordChange: true }
)
}
const existing = await tx.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(
eq(t.provider, bindingPayload.provider),
eq(t.providerUserId, bindingPayload.providerUserId)
)
})
if (existing && existing.userId !== user.id) {
throw createApiError(409, 'AUTH_OAUTH_BOUND_OTHER_USER', '该第三方账号已被其他用户绑定')
}
await syncOAuthIdentityAvatar(tx, currentUser, existing, {
provider: bindingPayload.provider,
providerUserId: bindingPayload.providerUserId,
providerUsername: bindingPayload.providerUsername,
avatar: bindingPayload.avatar
})
})
}
await db
.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: clientIp
})
.where(eq(users.id, user.id))
.catch((err) => console.error('Error updating user login info:', err))
// 生成Token
const { token: authToken } = await createAuthSession(event, user, '2fa')
const isSecure = isSecureRequest(event)
setCookie(event, 'auth-token', authToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
deleteCookie(event, 'pre-auth-token')
deleteCookie(event, 'binding-token')
const requirePasswordChange = await resolveRequirePasswordChange(user)
const passwordSetupState = getPasswordSetupState(user, requirePasswordChange)
return {
success: true,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
role: user.role,
email: user.email,
emailVerified: user.emailVerified,
forcePasswordChange: user.forcePasswordChange,
passwordChangedAt: user.passwordChangedAt,
requirePasswordChange,
// 兼容旧客户端字段名
needsPasswordChange: requirePasswordChange,
...passwordSetupState,
has2FA: true
}
}
})
+456
View File
@@ -0,0 +1,456 @@
import {
decodeOAuthStateCookie,
getOAuthStateCookieNames,
LEGACY_OAUTH_STATE_COOKIE_NAMES,
parseState,
getRedirectUri,
getSafeOAuthReturnPath,
verifyCompactOAuthState
} from '~~/server/utils/oauth'
import { generateBindingToken } from '~~/server/utils/oauth-token'
import { db, eq, users, systemSettings } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/auth-session'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
import {
getOAuthBaseConfig,
getProviderRuntimeConfig,
isOAuthProviderEnabled,
isSupportedOAuthProvider
} from '~~/server/services/oauthConfigService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTime } from '~/utils/timeUtils'
import type { H3Event } from 'h3'
import { getRequestOrigin, isSecureRequest } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
import { computeRequirePasswordChange } from '~~/server/utils/system-settings-helper'
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
const getSingleQueryValue = (value: unknown): string | undefined => {
return typeof value === 'string' ? value : undefined
}
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
const query = getQuery(event)
// OAuth 回调参数参与身份与 CSRF 校验,拒绝重复参数可避免上下游解析结果不一致。
const code = getSingleQueryValue(query.code)
const stateStr = getSingleQueryValue(query.state)
const callbackLoginType = getSingleQueryValue(query.type)?.trim().toLowerCase()
if (!provider) {
throw createApiError(400, 'AUTH_MISSING_PROVIDER', 'Missing provider')
}
if (!isSupportedOAuthProvider(provider)) {
throw createApiError(400, 'AUTH_UNSUPPORTED_OAUTH_PROVIDER', '当前仅支持 GitHub / Casdoor / Google / 聚合登陆 / 第三方 OAuth2')
}
const enabled = await isOAuthProviderEnabled(provider)
if (!enabled) {
return sendRedirect(
event,
`/auth/error?code=PROVIDER_DISABLED&message=${encodeURIComponent('该 OAuth 登录方式未启用')}`
)
}
if (!code) {
throw createApiError(400, 'AUTH_MISSING_CODE_OR_STATE', 'OAuth 回调缺少或包含冲突的 code 参数')
}
if (!stateStr) {
throw createApiError(400, 'AUTH_MISSING_CODE_OR_STATE', 'OAuth 回调缺少或包含冲突的 state 参数,无法完成安全验证')
}
// 1. 验证 State
const stateCookieNames = getOAuthStateCookieNames(provider === 'aggregate' ? stateStr : undefined)
let activeStateCookieNames = stateCookieNames
let csrfCookie = getCookie(event, activeStateCookieNames.csrf)
let storedFullState = getCookie(event, activeStateCookieNames.fullState)
let storedCompactState = getCookie(event, activeStateCookieNames.compactState)
// 兼容升级前已经发起、仍在十分钟有效期内的登录流程。
// 仅在新命名 Cookie 全部缺失时才回退,避免部分丢失时连同有效的新命名 Cookie 一起被丢弃。
if (provider === 'aggregate' && !csrfCookie && !storedFullState && !storedCompactState) {
activeStateCookieNames = LEGACY_OAUTH_STATE_COOKIE_NAMES
csrfCookie = getCookie(event, activeStateCookieNames.csrf)
storedFullState = getCookie(event, activeStateCookieNames.fullState)
storedCompactState = getCookie(event, activeStateCookieNames.compactState)
}
// 验证失败时同时清理两套命名下的残留 Cookie,避免无效状态干扰后续流程
const cleanupStateCookies = () => {
const names = new Set([
stateCookieNames.csrf,
LEGACY_OAUTH_STATE_COOKIE_NAMES.csrf,
...(provider === 'aggregate'
? [
stateCookieNames.fullState,
stateCookieNames.compactState,
LEGACY_OAUTH_STATE_COOKIE_NAMES.fullState,
LEGACY_OAUTH_STATE_COOKIE_NAMES.compactState
]
: [])
])
for (const name of names) {
deleteCookie(event, name, { path: '/' })
}
}
if (!csrfCookie) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_CSRF_COOKIE_MISSING', 'CSRF验证失败:Cookie丢失,请从登录页面重新开始')
}
// 获取 Origin
const origin = getRequestOrigin(event)
const { stateSecret, redirectUriTemplate } = await getOAuthBaseConfig()
const providerConfig = await getProviderRuntimeConfig(provider)
let stateToVerify = stateStr
if (provider === 'aggregate') {
// Cookie 比对确认回调属于本流程,HMAC 验签阻止伪造的 state
if (
!storedFullState ||
!storedCompactState ||
storedCompactState !== stateStr ||
!verifyCompactOAuthState(stateStr, stateSecret)
) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_AGGREGATED_STATE_INVALID', '聚合登录状态无效或已过期')
}
stateToVerify = decodeOAuthStateCookie(storedFullState)
if (!stateToVerify) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_AGGREGATED_STATE_INVALID', '聚合登录状态无效或已过期')
}
}
const state = parseState(stateToVerify, origin, csrfCookie, stateSecret)
if (!state) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_STATE_INVALID', 'Invalid or expired state')
}
if (state.provider && state.provider !== provider) {
throw createApiError(400, 'AUTH_OAUTH_PROVIDER_STATE_MISMATCH', 'OAuth provider 与 state 不匹配')
}
let identityProvider: string = provider
if (provider === 'aggregate') {
const loginType = state.loginType?.trim().toLowerCase()
if (!loginType || !providerConfig.loginTypes?.includes(loginType)) {
throw createApiError(400, 'AUTH_AGGREGATED_METHOD_CHANGED', '聚合登录方式未启用或已变更')
}
if (callbackLoginType !== loginType) {
throw createApiError(400, 'AUTH_AGGREGATED_CALLBACK_STATE_MISMATCH', '聚合登录回调类型与 state 不匹配')
}
providerConfig.loginType = loginType
identityProvider = `aggregate:${loginType}`
}
// 清除 CSRF cookie
deleteCookie(event, activeStateCookieNames.csrf, { path: '/' })
if (provider === 'aggregate') {
deleteCookie(event, activeStateCookieNames.fullState, { path: '/' })
deleteCookie(event, activeStateCookieNames.compactState, { path: '/' })
}
const strategy = getOAuthStrategy(provider)
const redirectUri = getRedirectUri(provider, redirectUriTemplate)
// 2. 使用 Code 换取 Token
let accessToken: string
try {
accessToken = await strategy.exchangeToken(code, redirectUri, providerConfig)
} catch (e: any) {
console.error(`[OAuth] ${provider} token exchange failed:`, e.message)
const errorMessage =
provider === 'aggregate' && typeof e?.message === 'string' && e.message.trim()
? `聚合登录授权失败:${e.message.trim()}`
: '授权失败,无法获取访问令牌'
return sendRedirect(
event,
`/auth/error?code=TOKEN_EXCHANGE_FAILED&message=${encodeURIComponent(errorMessage)}`
)
}
// 3. 获取用户信息
let userInfo
try {
userInfo = await strategy.getUserInfo(accessToken, providerConfig)
} catch (e: any) {
console.error(`[OAuth] ${provider} get user info failed:`, e.message)
return sendRedirect(
event,
`/auth/error?code=USER_INFO_FAILED&message=${encodeURIComponent('获取用户信息失败')}`
)
}
const providerUserId = userInfo.id
const providerUsername = userInfo.username
return handleUserLoginOrBind(
event,
identityProvider,
providerUserId,
providerUsername,
state.returnTo,
userInfo.avatar
)
})
async function handleUserLoginOrBind(
event: H3Event,
provider: string,
providerUserId: string,
providerUsername: string,
returnTo?: string,
avatar?: string
) {
const isSecure = isSecureRequest(event)
const safeReturnTo = getSafeOAuthReturnPath(returnTo)
// 4. 检查是否已登录(绑定模式)
const authToken = getCookie(event, 'auth-token')
let currentUser: any = null
if (authToken) {
try {
const payload = JWTEnhanced.verifyToken(authToken)
currentUser = payload
} catch (e) {
// Token 无效或已过期,忽略
}
}
// 5. 检查身份关联
const existingIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) => and(eq(t.provider, provider), eq(t.providerUserId, providerUserId)),
with: { user: true }
})
// 如果用户已登录,则是绑定操作
if (currentUser) {
let bindingResult:
| 'success'
| 'already-bound'
| 'bound-to-other'
| 'invalid-session'
| 'inactive'
| 'password-change-required'
try {
bindingResult = await db.transaction(async (tx) => {
const [currentUserRecord] = await tx
.select({
id: users.id,
status: users.status,
tokenVersion: users.tokenVersion,
forcePasswordChange: users.forcePasswordChange,
passwordChangedAt: users.passwordChangedAt,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId
})
.from(users)
.where(eq(users.id, currentUser.userId))
.for('update')
if (
!currentUserRecord ||
// 兼容迁移前签发的无版本号令牌,按版本 0 参与比对,与全局认证中间件语义一致
(currentUser.tokenVersion ?? 0) !== currentUserRecord.tokenVersion
) {
return 'invalid-session'
}
if (currentUserRecord.status !== 'active') {
return 'inactive'
}
const [currentSettings] = await tx
.select({
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
})
.from(systemSettings)
.limit(1)
.for('share')
const requirePasswordChange = computeRequirePasswordChange(
currentUserRecord,
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
)
if (!canBindOAuthIdentity(requirePasswordChange)) {
return 'password-change-required'
}
const identity = await tx.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, provider), eq(t.providerUserId, providerUserId))
})
if (identity) {
if (identity.userId !== currentUser.userId) {
return 'bound-to-other'
}
await syncOAuthIdentityAvatar(tx, currentUserRecord, identity, {
provider,
providerUserId,
providerUsername,
avatar
})
return 'already-bound'
}
await syncOAuthIdentityAvatar(tx, currentUserRecord, null, {
provider,
providerUserId,
providerUsername,
avatar
})
return 'success'
})
} catch (error: any) {
if (error?.code !== '23505') {
console.error('[OAuth] 绑定第三方账号失败:', error)
return sendRedirect(
event,
'/account?error=' + encodeURIComponent('绑定第三方账号失败,请稍后重试')
)
}
const concurrentIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, provider), eq(t.providerUserId, providerUserId))
})
bindingResult =
concurrentIdentity?.userId === currentUser.userId ? 'already-bound' : 'bound-to-other'
}
if (bindingResult === 'success') {
return sendRedirect(event, '/account?message=' + encodeURIComponent('绑定成功'))
}
if (bindingResult === 'already-bound') {
return sendRedirect(event, '/account?message=' + encodeURIComponent('账号已绑定'))
}
if (bindingResult === 'bound-to-other') {
return sendRedirect(event, '/account?error=' + encodeURIComponent('该账号已被其他用户绑定'))
}
if (bindingResult === 'password-change-required') {
return sendRedirect(
event,
'/change-password?error=' + encodeURIComponent('请先完成密码修改后再绑定第三方账号')
)
}
if (bindingResult === 'invalid-session') {
deleteCookie(event, 'auth-token')
return sendRedirect(event, '/login?error=' + encodeURIComponent('会话已失效,请重新登录'))
}
return sendRedirect(
event,
'/account?error=' + encodeURIComponent('当前账号状态异常,暂时无法绑定第三方账号')
)
}
// 未登录,则是登录或新绑定流程
if (existingIdentity && existingIdentity.user) {
// 检查用户状态
const user = existingIdentity.user
if (user.status === 'withdrawn') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_WITHDRAWN&message=${encodeURIComponent('该账号已退学,限制访问')}`
)
}
if (user.status === 'graduate') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_GRADUATED&message=${encodeURIComponent('该账号已毕业,限制访问')}`
)
}
if (user.status === 'pending') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_PENDING_APPROVAL&message=${encodeURIComponent('账号待管理员审核,请耐心等待')}`
)
}
if (user.status !== 'active') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_DISABLED&message=${encodeURIComponent('该账号当前不可用')}`
)
}
if (isUserBlocked(user.id)) {
const remaining = getUserBlockRemainingTime(user.id)
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_BLOCKED&message=${encodeURIComponent(`账户处于风险控制期,请在 ${remaining} 分钟后重试`)}`
)
}
await db.transaction(async (tx) => {
const [userRecord] = await tx
.select({
id: users.id,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId
})
.from(users)
.where(eq(users.id, user.id))
.for('update')
if (!userRecord) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
await syncOAuthIdentityAvatar(tx, userRecord, existingIdentity, {
provider,
providerUserId,
providerUsername,
avatar
})
await tx
.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: getClientIP(event)
})
.where(eq(users.id, user.id))
})
const { token } = await createAuthSession(event, existingIdentity.user, provider)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return sendRedirect(event, safeReturnTo || '/')
} else {
// 绑定
const bindingToken = generateBindingToken({
provider: provider,
providerUserId,
providerUsername,
avatar
})
// 将绑定令牌存入 cookie
setCookie(event, 'binding-token', bindingToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
const redirectQuery = safeReturnTo ? `&redirect=${encodeURIComponent(safeReturnTo)}` : ''
return sendRedirect(
event,
`/login?action=bind&provider=${encodeURIComponent(provider)}&username=${encodeURIComponent(providerUsername)}${redirectQuery}`
)
}
}
+150
View File
@@ -0,0 +1,150 @@
import {
encodeOAuthStateCookie,
generateCompactOAuthState,
generateState,
getOAuthStateCookieNames,
getRedirectUri,
getSafeOAuthReturnPath
} from '~~/server/utils/oauth'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import {
getOAuthBaseConfig,
getProviderRuntimeConfig,
isOAuthProviderEnabled,
isSupportedOAuthProvider
} from '~~/server/services/oauthConfigService'
import { getRequestOrigin, getSafeRequestProtocol } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
if (!provider) {
throw createApiError(400, 'AUTH_MISSING_PROVIDER', 'Missing provider')
}
if (!isSupportedOAuthProvider(provider)) {
throw createApiError(
400,
'AUTH_UNSUPPORTED_OAUTH_PROVIDER',
'当前仅支持 GitHub / Casdoor / Google / 聚合登陆 / 第三方 OAuth2'
)
}
const enabled = await isOAuthProviderEnabled(provider)
if (!enabled) {
throw createApiError(403, 'AUTH_OAUTH_PROVIDER_DISABLED', 'OAuth provider is disabled')
}
const query = getQuery(event)
const { stateSecret, redirectUriTemplate } = await getOAuthBaseConfig()
const providerConfig = await getProviderRuntimeConfig(provider)
let aggregateLoginType: string | undefined
if (provider === 'aggregate') {
aggregateLoginType =
typeof query.type === 'string' ? query.type.trim().toLowerCase() : undefined
if (!aggregateLoginType || !providerConfig.loginTypes?.includes(aggregateLoginType)) {
throw createApiError(400, 'AUTH_AGGREGATED_LOGIN_UNSUPPORTED', '未启用或不支持的聚合登录方式')
}
providerConfig.loginType = aggregateLoginType
}
const strategy = getOAuthStrategy(provider)
// 获取 Origin
const origin = getRequestOrigin(event)
const protocol = getSafeRequestProtocol(event)
const redirectUri = getRedirectUri(provider, redirectUriTemplate)
// 代理平台可能重写服务端可见的 Host,且 Auth-Broker 场景下回调域名本就与源站不同,
// 因此这里只校验回调地址格式,回调请求仍会通过 state、CSRF 和 host-only Cookie 完成来源验证。
try {
const redirectUrl = new URL(redirectUri)
if (!['http:', 'https:'].includes(redirectUrl.protocol)) {
throw new Error('unsupported protocol')
}
} catch {
throw createApiError(
400,
'AUTH_OAUTH_REDIRECT_INVALID',
'OAuth 重定向 URI 配置无效,请在管理员后台检查配置'
)
}
const returnTo = getSafeOAuthReturnPath(query.redirect)
const { state, csrf } = generateState(origin, provider, stateSecret, returnTo, aggregateLoginType)
// 在开发环境 (HTTP) 中,必须将 secure 设置为 false,否则浏览器会拒绝设置 cookie
const isHttps = protocol === 'https'
let authorizeState = state
if (provider === 'aggregate') {
authorizeState = generateCompactOAuthState(origin, stateSecret)
// 限制并发授权流程数量,防止被放弃的流程在 Cookie 中无限累积导致请求头超限
const existingCookies = parseCookies(event)
const pendingSuffixes = new Set<string>()
for (const name of Object.keys(existingCookies)) {
const suffix = name.match(/^oauth_(?:csrf|full_state|compact_state)_(.+)$/)?.[1]
if (suffix) pendingSuffixes.add(suffix)
}
if (pendingSuffixes.size >= 3) {
for (const suffix of pendingSuffixes) {
deleteCookie(event, `oauth_csrf_${suffix}`, { path: '/' })
deleteCookie(event, `oauth_full_state_${suffix}`, { path: '/' })
deleteCookie(event, `oauth_compact_state_${suffix}`, { path: '/' })
}
}
}
const stateCookieNames = getOAuthStateCookieNames(
provider === 'aggregate' ? authorizeState : undefined
)
const stateCookieOptions = {
httpOnly: true,
secure: isHttps,
sameSite: 'lax' as const,
maxAge: 60 * 10,
path: '/'
}
// 不指定 domain,让每个授权流程的 Cookie 绑定到当前 host 和独立 state。
setCookie(event, stateCookieNames.csrf, csrf, stateCookieOptions)
if (provider === 'aggregate') {
setCookie(event, stateCookieNames.fullState, encodeOAuthStateCookie(state), stateCookieOptions)
setCookie(event, stateCookieNames.compactState, authorizeState, stateCookieOptions)
}
let url: string
try {
url = await strategy.getAuthorizeUrl(redirectUri, authorizeState, providerConfig)
} catch (error: any) {
if (provider !== 'aggregate') throw error
deleteCookie(event, stateCookieNames.csrf, { path: '/' })
deleteCookie(event, stateCookieNames.fullState, { path: '/' })
deleteCookie(event, stateCookieNames.compactState, { path: '/' })
const statusCode = error?.statusCode || 500
console.error('聚合登录方式暂不可用', {
loginType: aggregateLoginType,
statusCode,
message: error?.message
})
// 区分服务端配置缺失与上游未开通,避免误导管理员排查方向
const isSecurityChallenge =
typeof error?.message === 'string' && error.message.includes('安全防护拦截')
const errorMessage =
statusCode === 500
? '聚合登录配置不完整,请联系管理员检查配置。'
: isSecurityChallenge
? error.message
: '当前登录方式暂不可用,可能尚未在聚合登录服务中开通。请尝试其他登录方式或联系管理员。'
return sendRedirect(
event,
`/auth/error?code=AGGREGATE_LOGIN_UNAVAILABLE&message=${encodeURIComponent(errorMessage)}`
)
}
return sendRedirect(event, url)
})
+252
View File
@@ -0,0 +1,252 @@
import bcrypt from 'bcryptjs'
import { db, eq, users, userIdentities, systemSettings } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/auth-session'
import { verifyBindingToken } from '~~/server/utils/oauth-token'
import {
isAccountLocked,
isUserBlocked,
getUserBlockRemainingTime,
recordLoginFailure,
recordLoginSuccess
} from '~~/server/services/securityService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { and } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { isSecureRequest } from '~~/server/utils/request-utils'
import {
computeRequirePasswordChange,
resolveRequirePasswordChange
} from '~~/server/utils/system-settings-helper'
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
import { createApiError } from '~~/server/utils/apiError'
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, unknown> | null>(event)
const username = typeof body?.username === 'string' ? body.username : ''
const password = typeof body?.password === 'string' ? body.password : ''
const bindingToken = getCookie(event, 'binding-token')
if (!bindingToken) {
throw createApiError(
400,
'AUTH_BINDING_SESSION_EXPIRED',
'绑定会话已过期,请重新通过第三方登录发起'
)
}
let payload
try {
payload = verifyBindingToken(bindingToken)
} catch (e) {
deleteCookie(event, 'binding-token')
throw createApiError(400, 'AUTH_INVALID_BINDING_TOKEN', '无效的绑定令牌')
}
const clientIp = getClientIP(event)
// 验证用户凭据
if (await isAccountLocked(username)) {
throw createApiError(423, 'AUTH_ACCOUNT_LOCKED', '账户已被锁定,请稍后重试')
}
const user = await db.query.users.findFirst({
where: eq(users.username, username)
})
if (!user) {
await recordLoginFailure(username, clientIp)
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
}
const valid = !!user.password && (await bcrypt.compare(password, user.password))
if (!valid) {
await recordLoginFailure(username, clientIp)
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
}
if (user.status === 'withdrawn') {
throw createApiError(403, 'AUTH_ACCOUNT_WITHDRAWN', '该账号已退学,限制访问')
}
if (user.status === 'graduate') {
throw createApiError(403, 'AUTH_ACCOUNT_GRADUATED', '该账号已毕业,限制访问')
}
if (user.status === 'pending') {
throw createApiError(403, 'AUTH_USER_PENDING_APPROVAL', '账号待管理员审核,请耐心等待')
}
if (user.status !== 'active') {
throw createApiError(403, 'AUTH_ACCOUNT_CURRENTLY_UNAVAILABLE', '该账号当前不可用')
}
if (isUserBlocked(user.id)) {
const remaining = getUserBlockRemainingTime(user.id)
throw createApiError(
423,
'AUTH_ACCOUNT_RISK_CONTROL',
`账户处于风险控制期,请在 ${remaining} 分钟后重试`,
{ params: [remaining] }
)
}
if (!canBindOAuthIdentity(await resolveRequirePasswordChange(user))) {
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
throw createApiError(
403,
'AUTH_PASSWORD_CHANGE_REQUIRED',
'请先完成密码修改后再绑定第三方账号',
{ requirePasswordChange: true }
)
}
const totpIdentity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp'))
})
if (totpIdentity) {
const tempToken = JWTEnhanced.sign(
{
userId: user.id,
tokenVersion: user.tokenVersion,
type: 'pre-auth',
scope: '2fa_pending'
},
{ expiresIn: '5m' }
)
const methods = ['totp']
let maskedEmail = ''
if (user.email && user.emailVerified) {
methods.push('email')
const [local, domain] = user.email.split('@')
if (local && domain) {
maskedEmail = local.length <= 2 ? `***@${domain}` : `${local.slice(0, 2)}****@${domain}`
}
}
const isSecure = isSecureRequest(event)
setCookie(event, 'pre-auth-token', tempToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 5,
path: '/'
})
return {
success: true,
requires2FA: true,
userId: user.id,
methods,
maskedEmail
}
}
// 绑定
try {
await db.transaction(async (tx) => {
const [currentUser] = await tx
.select({
id: users.id,
tokenVersion: users.tokenVersion,
forcePasswordChange: users.forcePasswordChange,
passwordChangedAt: users.passwordChangedAt,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId
})
.from(users)
.where(eq(users.id, user.id))
.for('update')
if (!currentUser || currentUser.tokenVersion !== user.tokenVersion) {
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
}
const [currentSettings] = await tx
.select({
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
})
.from(systemSettings)
.limit(1)
.for('share')
const requirePasswordChange = computeRequirePasswordChange(
currentUser,
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
)
if (!canBindOAuthIdentity(requirePasswordChange)) {
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
throw createApiError(
403,
'AUTH_PASSWORD_CHANGE_REQUIRED',
'请先完成密码修改后再绑定第三方账号',
{ requirePasswordChange: true }
)
}
const existing = await tx.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
})
if (existing && existing.userId !== user.id) {
throw createApiError(409, 'AUTH_OAUTH_BOUND_OTHER_USER', '该第三方账号已被其他用户绑定')
}
await syncOAuthIdentityAvatar(tx, currentUser, existing, {
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername,
avatar: payload.avatar
})
})
} catch (e: any) {
// 如果用户尝试再次绑定相同的账户,处理唯一约束违规
if (e.code === '23505') {
// Postgres 唯一性冲突
// 这意味着身份已经绑定,这是正常情况。
// 我们可以继续执行登录流程。
} else {
// 绑定失败,清除 cookie 防止重放,或者保留允许重试?
// 如果是系统错误,保留 cookie 可能更好。
// 但如果是逻辑错误,应该清除。
// 这里我们选择抛出错误,前端处理。
throw e
}
}
await recordLoginSuccess(username, clientIp)
await db
.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: clientIp
})
.where(eq(users.id, user.id))
// 登录
const { token } = await createAuthSession(event, user, payload.provider || 'oauth')
const isSecure = isSecureRequest(event)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
// 清除绑定令牌
deleteCookie(event, 'binding-token')
deleteCookie(event, 'pre-auth-token')
return { success: true }
})
+24
View File
@@ -0,0 +1,24 @@
import { defineEventHandler } from 'h3'
import { saveCaptcha } from '~~/server/utils/captcha'
import svgCaptcha from 'svg-captcha'
export default defineEventHandler(async () => {
const captcha = svgCaptcha.create({
size: 4, // 4 位字符
ignoreChars: '0o1il', // 剔除易混淆字符
noise: 1, // 干扰线从2减到1,减少视觉杂乱
color: true, // 彩色字符
background: '#ffffff', // 浅白背景,提高对比度
width: 120, // 显式指定宽度,让SVG适配容器
height: 40 // 显式指定高度
})
const captchaId = crypto.randomUUID()
// 存入统一存储,5 分钟有效
await saveCaptcha(captchaId, captcha.text)
return {
id: captchaId,
svg: captcha.data // 完整的 <svg>...</svg> 字符串
}
})
+145
View File
@@ -0,0 +1,145 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { recordLoginFailure, recordLoginSuccess } from '../../services/securityService'
import { updateUserPassword } from '../../services/userService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { createApiError } from '~~/server/utils/apiError'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { isSecureRequest } from '~~/server/utils/request-utils'
import { getPasswordPolicyViolation } from '~/utils/password-policy'
import {
PASSWORD_AUDIT_ACTIONS,
consumePasswordRateLimit,
getPasswordAuditContext,
recordPasswordAudit
} from '~~/server/services/passwordSecurityService'
export default defineEventHandler(async (event) => {
// 验证用户身份
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED', '未授权')
}
const auditAction = PASSWORD_AUDIT_ACTIONS.CHANGE_PASSWORD
const clientIp = getClientIP(event)
const body = await readBody<Record<string, unknown> | null>(event)
const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : ''
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
if (!currentPassword || !newPassword) {
await recordPasswordAudit(event, user.id, auditAction, false, '缺少当前密码或新密码')
throw createApiError(
400,
'AUTH_CURRENT_AND_NEW_PASSWORD_REQUIRED',
'当前密码和新密码都是必需的'
)
}
const rateLimit = await consumePasswordRateLimit(user.id, clientIp, auditAction, 10)
if (!rateLimit.allowed) {
await recordPasswordAudit(event, user.id, auditAction, false, '操作频率超过限制')
setResponseHeader(event, 'Retry-After', String(rateLimit.retryAfterSeconds))
const retryAfterMinutes = Math.max(1, Math.ceil(rateLimit.retryAfterSeconds / 60))
throw createApiError(
429,
'AUTH_RATE_LIMITED_MINUTES',
`密码修改尝试过于频繁,请 ${retryAfterMinutes} 分钟后再试`,
{ params: [retryAfterMinutes] }
)
}
const policyViolation = getPasswordPolicyViolation(newPassword)
if (policyViolation) {
await recordPasswordAudit(event, user.id, auditAction, false, policyViolation.message)
throw createApiError(400, policyViolation.code, policyViolation.message)
}
let passwordUpdated = false
try {
// 查询用户详细信息
const userDetailsResult = await db
.select({
id: users.id,
username: users.username,
password: users.password,
tokenVersion: users.tokenVersion
})
.from(users)
.where(eq(users.id, user.id))
.limit(1)
const userDetails = userDetailsResult[0]
if (!userDetails) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
// 验证当前密码
if (!userDetails.password) {
throw createApiError(
400,
'AUTH_PASSWORD_NOT_SET',
'当前账号尚未设置密码,请使用初始密码设置功能'
)
}
const isPasswordValid = await bcrypt.compare(currentPassword, userDetails.password)
if (!isPasswordValid) {
// 记录安全事件
await recordLoginFailure(userDetails.username, clientIp)
throw createApiError(400, 'AUTH_CURRENT_PASSWORD_INCORRECT', '当前密码不正确')
}
// 检查新密码是否与当前密码相同
const isSamePassword = await bcrypt.compare(newPassword, userDetails.password)
if (isSamePassword) {
throw createApiError(400, 'AUTH_NEW_PASSWORD_SAME_AS_CURRENT', '新密码不能与当前密码相同')
}
// 更新密码
const { passwordChangedAt, tokenVersion } = await updateUserPassword(user.id, newPassword, {
expectedTokenVersion: userDetails.tokenVersion,
auditContext: { action: auditAction, ...getPasswordAuditContext(event) }
})
passwordUpdated = true
// 密码变更会让旧令牌失效,因此为当前已验证会话立即换发令牌。
const newToken = JWTEnhanced.generateToken(user.id, user.role, tokenVersion, event.context.authSessionId)
setCookie(event, 'auth-token', newToken, {
httpOnly: true,
secure: isSecureRequest(event),
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
// 记录成功的密码修改
await recordLoginSuccess(userDetails.username, clientIp)
return {
success: true,
message: '密码修改成功',
passwordChangedAt
}
} catch (error: any) {
if (!passwordUpdated) {
await recordPasswordAudit(event, user.id, auditAction, false, error.message || '密码修改失败')
}
// 已格式化的错误直接抛出
if (error.statusCode) {
throw error
}
// 记录错误信息
console.error('修改密码过程中发生未处理的错误:', error)
// 创建错误响应
throw createApiError(500, 'AUTH_SYSTEM_ERROR', '修改密码失败,请稍后重试')
}
})
+79
View File
@@ -0,0 +1,79 @@
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { getClientIP } from '~~/server/utils/ip-utils'
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { SmtpService } from '~~/server/services/smtpService'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import {
generateEmailCode,
storeEmailCode,
isEmailCodeCooldownActive,
isEmailCodeDailyLimitReached,
cleanupExpiredEmailCodes
} from '~~/server/utils/email-verification'
const EMAIL_CODE_IP_LIMIT = 10
const EMAIL_CODE_IP_WINDOW_MS = 60 * 1000
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const email = typeof body.email === 'string' ? body.email.trim() : ''
const clientIp = getClientIP(event)
if (!email || !EMAIL_REGEX.test(email)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请输入有效的邮箱地址')
}
// 注册/三方注册任一开放时允许发码(否则拒绝,避免成为邮件群发通道)
const config = await db.query.systemSettings.findFirst()
if (!config?.allowRegister && !config?.allowOAuthRegistration) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_REGISTER_DISABLED, '系统未开放注册')
}
// IP 限流:每分钟最多 10 次
const limitResult = await checkDistributedRateLimit(`email-code:${clientIp}`, EMAIL_CODE_IP_LIMIT, EMAIL_CODE_IP_WINDOW_MS)
if (!limitResult.isAllowed) {
const waitSeconds = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 1000)
throw createApiError(429, SERVER_ERROR_CODES.AUTH_RATE_LIMITED_SECONDS, `操作过于频繁,请 ${waitSeconds} 秒后再试`, { params: [waitSeconds] })
}
// 同邮箱 60 秒冷却
if (await isEmailCodeCooldownActive(email)) {
throw createApiError(429, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_COOLDOWN, '验证码发送过于频繁,请 1 分钟后再试')
}
// 同一邮箱每日发送上限
if (await isEmailCodeDailyLimitReached(email)) {
throw createApiError(429, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_DAILY_LIMIT, '今日验证码发送次数已达上限,请明天再试')
}
// 邮件服务可用性检查
const smtpService = SmtpService.getInstance()
if (!(await smtpService.ensureInitialized())) {
throw createApiError(503, SERVER_ERROR_CODES.AUTH_EMAIL_SERVICE_UNAVAILABLE, '邮件服务未配置或不可用,请联系管理员')
}
// 先发邮件,发送成功后才落码(失败不落码,也不触发冷却)
const code = generateEmailCode()
const sent = await smtpService.renderAndSend(email, 'verification.code', {
title: 'VoiceHub 注册邮箱验证',
// 补 builtin verification 模板所需变量(code 必填)
name: '用户',
email,
code,
expiresInMinutes: 5
})
if (!sent) {
throw createApiError(503, SERVER_ERROR_CODES.AUTH_EMAIL_SERVICE_UNAVAILABLE, '验证码邮件发送失败,请稍后重试')
}
cleanupExpiredEmailCodes()
await storeEmailCode(email, code)
return { success: true }
})
+125
View File
@@ -0,0 +1,125 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { SmtpService } from '~~/server/services/smtpService'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { formatIPForEmail, getClientIP } from '~~/server/utils/ip-utils'
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const clientIP = getClientIP(event)
// IP 级别限流:每小时最多 5 次请求
const rateLimitKey = `forgot_password_ip:${clientIP}`
const limitResult = await checkDistributedRateLimit(rateLimitKey, 5, 60 * 60 * 1000)
if (!limitResult.isAllowed) {
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
throw createApiError(429, 'AUTH_RATE_LIMITED_MINUTES', `操作过于频繁,请等待 ${waitMinutes} 分钟后再试`, { params: [waitMinutes] })
}
const body = await readBody(event)
const { username, email } = body
if (!username) {
throw createApiError(400, 'AUTH_ACCOUNT_NAME_REQUIRED', '请提供账号名')
}
try {
const userResult = await db.select().from(users).where(eq(users.username, username)).limit(1)
const user = userResult[0]
// 如果未提供邮箱,则进入第一步:检查账号并返回掩码邮箱
if (!email) {
if (!user) {
throw createApiError(404, 'AUTH_ACCOUNT_NAME_NOT_FOUND', '找不到该账号名,请检查是否拼写错误')
}
if (!user.email) {
throw createApiError(400, 'AUTH_NO_SECURITY_EMAIL', '该账号未绑定安全邮箱,无法通过此方式找回密码。请联系管理员。')
}
// 生成掩码邮箱 (例如: a***b@gmail.com)
const emailParts = user.email.split('@')
const name = emailParts[0]
const domain = emailParts[1]
let maskedName = name
if (name && name.length > 2) {
maskedName = name.substring(0, 1) + '***' + name.substring(name.length - 1)
} else if (name && name.length === 2) {
maskedName = name.substring(0, 1) + '*'
}
const maskedEmail = `${maskedName}@${domain}`
return {
success: true,
step: 2,
maskedEmail,
message: '请输入完整的邮箱地址以验证身份'
}
}
// 无论是否匹配,都返回相同的成功提示
if (user && user.email && user.email.toLowerCase() === email.toLowerCase()) {
const payload = {
type: 'password_reset',
userId: user.id,
// 仅截取密码哈希的前10位,防止完整哈希泄露被离线破解
hash: user.password.substring(0, 10)
}
const token = JWTEnhanced.sign(payload, { expiresIn: '15m' })
// 修复 Host Header Injection 风险,优先使用环境变量中配置的主机名
const config = useRuntimeConfig()
let resetLink = ''
if (config.public.host) {
// 如果配置了主机名,则直接使用
const protocol = config.public.host.startsWith('http')
? ''
: process.env.NODE_ENV === 'production'
? 'https://'
: 'http://'
resetLink = `${protocol}${config.public.host}/reset-password?token=${token}`
} else {
// 降级回退:使用安全的请求头获取方式
const host = getRequestHost(event)
const finalProtocol = getRequestProtocol(event)
resetLink = `${finalProtocol}://${host}/reset-password?token=${token}`
}
const smtp = SmtpService.getInstance()
// 确保SMTP配置已初始化
await smtp.initializeSmtpConfig()
const htmlContent = smtp.generateEmailTemplate(
'重置密码',
`<p>您好,您请求了重置密码。</p><p>请点击下方按钮重置密码(链接在15分钟内有效)。</p><p style="color:#888">如果您没有请求重置密码,请忽略此邮件。</p>`,
resetLink,
formatIPForEmail(clientIP),
true
)
event.waitUntil(
smtp
.sendMail(user.email, '重置密码 | VoiceHub', htmlContent, undefined, clientIP)
.catch((err) => console.error('发送重置密码邮件失败:', err))
)
}
return {
success: true,
step: 3,
message:
'如果账号名和邮箱匹配,重置密码链接已发送至您的邮箱。请查收并按照邮件中的说明重置密码。'
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
console.error('重置密码请求失败:', error)
throw createApiError(500, 'AUTH_SYSTEM_ERROR', '系统错误,请稍后重试')
}
})
@@ -0,0 +1,11 @@
import { fetchGradeClassOptions } from '~~/server/utils/grade-class-options'
// 公开的年级班级选项接口(注册表单使用,无需 binding-token)
export default defineEventHandler(async () => {
const classes = await fetchGradeClassOptions()
return {
success: true,
classes
}
})
+68
View File
@@ -0,0 +1,68 @@
import { db, eq, userIdentities } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { createApiError } from '~~/server/utils/apiError'
import { getIdentityAvatarUrl } from '~~/server/utils/user-avatar'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
const userRecord = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
avatarProvider: true,
avatarProviderUserId: true
}
})
const identities = await db.query.userIdentities.findMany({
where: eq(userIdentities.userId, user.id),
columns: {
id: true, // 需要 id 以便单独删除
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
return identities.map(identity => {
if (identity.provider === 'webauthn') {
try {
const data = JSON.parse(identity.providerUsername)
return {
...identity,
providerUsername: data.label || 'WebAuthn 设备',
avatar: null,
isAvatarSource: false
// 可以在这里把 label 改为 userFriendlyName
}
} catch {
return {
...identity,
providerUsername: '未知设备',
avatar: null,
isAvatarSource: false
}
}
}
if (identity.provider === 'totp') {
return {
...identity,
avatar: null,
isAvatarSource: false
}
}
const isAvatarSource =
userRecord?.avatarProvider === identity.provider &&
userRecord?.avatarProviderUserId === identity.providerUserId
return {
...identity,
avatar: getIdentityAvatarUrl(identity),
isAvatarSource
}
})
})
+316
View File
@@ -0,0 +1,316 @@
import bcrypt from 'bcryptjs'
import { db, eq, users, userIdentities, and, systemSettings } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import {
getAccountLockRemainingTime,
getIPBlockRemainingTime,
isAccountLocked,
isIPBlocked,
recordLoginFailure,
recordLoginSuccess,
recordAccountIpLogin,
blockUser,
getUserBlockRemainingTime,
//导入失败计数查询函数
getLoginFailureCount
} from '../../services/securityService'
import { getBeijingTime } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
import { resolveRequirePasswordChange } from '~~/server/utils/system-settings-helper'
import { getPasswordSetupState } from '~~/server/utils/initial-password-policy'
// 导入验证码校验函数
import { verifyAndConsumeCaptcha } from '~~/server/utils/captcha'
import type { SystemSettings } from '~/drizzle/schema'
import { createApiError } from '~~/server/utils/apiError'
import { createAuthSession } from '~~/server/utils/auth-session'
export default defineEventHandler(async (event) => {
const startTime = Date.now()
try {
const body = await readBody(event)
const clientIp = getClientIP(event)
let captchaId = ''
let captchaInput = ''
if (!body.username || !body.password) {
throw createApiError(400, 'AUTH_USERNAME_PASSWORD_REQUIRED', '用户名和密码不能为空')
}
if (!process.env.JWT_SECRET) {
console.error('JWT_SECRET environment variable is not set')
throw createApiError(500, 'AUTH_SERVER_CONFIG_ERROR', '服务器配置错误')
}
// 数据库连接检查 - 使用简单的查询测试连接
try {
await db.select().from(users).limit(1)
} catch (error) {
console.error('Database connection error:', error)
throw createApiError(503, 'AUTH_DATABASE_UNAVAILABLE', '数据库服务暂时不可用')
}
// 检查IP是否被限制
if (isIPBlocked(clientIp)) {
const remainingTime = getIPBlockRemainingTime(clientIp)
throw createApiError(423, 'AUTH_IP_BLOCKED_RETRY_MINUTES', `您的IP地址已被限制访问,请在 ${remainingTime} 分钟后重试`, { params: [remainingTime] })
}
// 检查账户是否被锁定
if (await isAccountLocked(body.username)) {
const remainingTime = await getAccountLockRemainingTime(body.username)
throw createApiError(423, 'AUTH_ACCOUNT_LOCKED_MINUTES', `账户已被锁定,请在 ${remainingTime} 分钟后重试`, { params: [remainingTime] })
}
// 读取全局配置:是否启用图形验证码
let captchaEnabled = false
let captchaProvider = 'graphic'
let turnstileSecretKey = ''
let captchaMaxFailures = 3
try {
const settings = await db
.select()
.from(systemSettings)
.limit(1)
.then((rows) => rows[0] as SystemSettings | undefined)
if (settings?.captchaEnabled) {
captchaEnabled = true
captchaProvider = settings.captchaProvider || 'graphic'
turnstileSecretKey = settings.turnstileSecretKey || ''
if (settings.captchaMaxFailures) {
captchaMaxFailures = settings.captchaMaxFailures
}
}
} catch (e) {
// 查询异常(如表不存在)时默认关闭验证码,保证登录可用
console.warn('读取验证码配置失败,已暂时禁用:', e)
}
// 图形验证码检查
let needCaptcha = false
if (captchaEnabled) {
if (captchaProvider === 'turnstile') {
needCaptcha = true // Turnstile 每次都验证
} else {
const failCount = await getLoginFailureCount(body.username)
needCaptcha = failCount >= captchaMaxFailures
}
}
// 验证码校验
if (needCaptcha) {
if (captchaProvider === 'turnstile') {
const turnstileToken = body.turnstileToken
if (!turnstileSecretKey) {
console.error('Turnstile is enabled but secret key is missing!')
throw createApiError(500, 'AUTH_CODE_SERVICE_CONFIG_ERROR', '验证码服务配置错误,请联系管理员')
}
if (!turnstileToken) {
throw createApiError(400, 'AUTH_CAPTCHA_REQUIRED', '请完成人机验证', { captchaRequired: true, captchaProvider: 'turnstile' })
}
const verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const formData = new URLSearchParams()
formData.append('secret', turnstileSecretKey)
formData.append('response', turnstileToken)
formData.append('remoteip', clientIp)
try {
const result: any = await $fetch(verifyUrl, {
method: 'POST',
body: formData,
timeout: 5000
})
if (!result.success) {
console.error('Turnstile verification failed:', result['error-codes'])
throw createApiError(400, 'AUTH_CAPTCHA_FAILED_OR_EXPIRED', '人机验证失败或已过期,请重试', { captchaRequired: true, captchaProvider: 'turnstile' })
}
} catch (err: any) {
if (err.statusCode === 400) throw err
console.error('Turnstile verification error:', err)
throw createApiError(500, 'AUTH_CAPTCHA_SERVICE_UNAVAILABLE', '人机验证服务暂时不可用')
}
} else {
captchaId = body.captchaId
captchaInput = body.captchaInput
if (!captchaId || !captchaInput) {
throw createApiError(400, 'AUTH_IMAGE_CAPTCHA_REQUIRED', '请完成图形验证码', { captchaRequired: true, captchaProvider: 'graphic' })
}
const isValid = await verifyAndConsumeCaptcha(captchaId, captchaInput)
if (!isValid) {
throw createApiError(400, 'AUTH_CODE_INVALID_OR_EXPIRED', '验证码错误或已过期,请重新输入', { captchaRequired: true, captchaProvider: 'graphic' })
}
}
}
// 查找用户
const userResult = await db
.select({
id: users.id,
username: users.username,
name: users.name,
grade: users.grade,
class: users.class,
password: users.password,
role: users.role,
lastLogin: users.lastLogin,
lastLoginIp: users.lastLoginIp,
passwordChangedAt: users.passwordChangedAt,
forcePasswordChange: users.forcePasswordChange,
tokenVersion: users.tokenVersion,
status: users.status,
email: users.email,
emailVerified: users.emailVerified
})
.from(users)
.where(eq(users.username, body.username))
.limit(1)
const user = userResult[0] || null
if (!user) {
// 记录登录失败(用户不存在)
await recordLoginFailure(body.username, clientIp)
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
}
// 验证密码
const isPasswordValid = await bcrypt.compare(body.password, user.password)
if (!isPasswordValid) {
// 记录登录失败(密码错误)
await recordLoginFailure(body.username, clientIp)
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
}
// 检查用户状态 (移到2FA之前,防止已退学用户进行2FA验证)
if (user.status === 'withdrawn') {
throw createApiError(403, 'AUTH_ACCOUNT_WITHDRAWN', '该账号已退学,限制访问')
} else if (user.status === 'graduate') {
throw createApiError(403, 'AUTH_ACCOUNT_GRADUATED', '该账号已毕业,限制访问')
} else if (user.status === 'banned') {
throw createApiError(403, 'AUTH_ACCOUNT_BANNED', '该账号已被封禁')
} else if (user.status === 'pending') {
throw createApiError(403, 'AUTH_USER_PENDING_APPROVAL', '账号待管理员审核,请耐心等待')
} else if (user.status === 'rejected') {
throw createApiError(403, 'AUTH_ACCOUNT_CURRENTLY_UNAVAILABLE', '该账号当前不可用')
}
// 普通登录不延续绑定流程,避免残留绑定令牌被后续 2FA 校验意外消费
deleteCookie(event, 'binding-token')
// 检查是否开启2FA
const totpIdentity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp'))
})
if (totpIdentity) {
// 生成预认证临时令牌
const tempToken = JWTEnhanced.sign(
{
userId: user.id,
tokenVersion: user.tokenVersion,
type: 'pre-auth',
scope: '2fa_pending'
},
{ expiresIn: '5m' }
) // 动态构建验证方式列表
const methods = ['totp']
let maskedEmail = ''
if (user.email && user.emailVerified) {
methods.push('email')
// 生成脱敏邮箱提示
const [local, domain] = user.email.split('@')
if (local && domain) {
maskedEmail = local.length <= 2 ? `***@${domain}` : `${local.slice(0, 2)}****@${domain}`
}
}
return {
success: true,
requires2FA: true,
userId: user.id,
methods,
maskedEmail,
tempToken
}
}
await recordLoginSuccess(body.username, clientIp)
const ipSwitchExceeded = recordAccountIpLogin(body.username, clientIp)
if (ipSwitchExceeded) {
blockUser(user.id)
const ipRemain = getIPBlockRemainingTime(clientIp)
const userRemain = getUserBlockRemainingTime(user.id)
throw createApiError(423, 'AUTH_MULTI_IP_PROTECTION', `检测到同一账号短期多IP登录,当前IP限制 ${ipRemain} 分钟,账户保护 ${userRemain} 分钟`, { params: [ipRemain, userRemain] })
}
// 更新登录信息
await db
.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: clientIp
})
.where(eq(users.id, user.id))
.catch((err) => console.error('Error updating user login info:', err))
// 生成JWT
const { token } = await createAuthSession(event, user, 'password')
// 自动判断是否需要secure
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
// 设置cookie
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
const processingTime = Date.now() - startTime
console.log(`Login for ${user.username} processed in ${processingTime}ms`)
const requirePasswordChange = await resolveRequirePasswordChange(user)
const passwordSetupState = getPasswordSetupState(user, requirePasswordChange)
return {
success: true,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
role: user.role,
email: user.email,
emailVerified: user.emailVerified,
forcePasswordChange: user.forcePasswordChange,
passwordChangedAt: user.passwordChangedAt,
requirePasswordChange,
// 兼容旧客户端字段名
needsPasswordChange: requirePasswordChange,
...passwordSetupState
}
}
} catch (error: any) {
const errorTime = Date.now() - startTime
console.error(`Login error after ${errorTime}ms:`, error)
if (error.statusCode) {
throw error
}
throw createApiError(500, 'AUTH_LOGIN_UNKNOWN_ERROR', '登录过程中发生未知错误')
}
})
+50
View File
@@ -0,0 +1,50 @@
import { revokeAuthSession } from '~~/server/utils/auth-session'
export default defineEventHandler(async (event) => {
try {
console.log('[Auth] User logout requested')
if (event.context.user?.id && event.context.authSessionId) {
await revokeAuthSession(event.context.user.id, event.context.authSessionId, 'logout')
}
// 获取当前用户信息(如果存在)
// 清除cookie
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', '', {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 0,
path: '/'
})
return {
success: true,
message: '登出成功'
}
} catch (error: any) {
console.error('Logout error:', error)
// 出错时也要清除cookie
try {
setCookie(event, 'auth-token', '', {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 0,
path: '/'
})
} catch (cookieError) {
console.error('Failed to clear cookie:', cookieError)
}
// 返回成功状态
return {
success: true,
message: '登出成功'
}
}
})
@@ -0,0 +1,31 @@
import { db } from '~/drizzle/db'
import { verifyBindingToken } from '~~/server/utils/oauth-token'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { fetchGradeClassOptions } from '~~/server/utils/grade-class-options'
export default defineEventHandler(async (event) => {
const config = await db.query.systemSettings.findFirst()
if (!config?.allowOAuthRegistration) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_OAUTH_REGISTER_DISABLED, '系统已关闭第三方账号注册功能')
}
const bindingToken = getCookie(event, 'binding-token')
if (!bindingToken) {
throw createApiError(401, SERVER_ERROR_CODES.AUTH_REGISTER_SESSION_EXPIRED, '注册会话已过期,请重新通过第三方登录发起')
}
try {
verifyBindingToken(bindingToken)
} catch {
deleteCookie(event, 'binding-token')
throw createApiError(401, SERVER_ERROR_CODES.AUTH_INVALID_REGISTER_TOKEN, '无效的注册令牌')
}
const classes = await fetchGradeClassOptions()
return {
success: true,
classes
}
})
+217
View File
@@ -0,0 +1,217 @@
import bcrypt from 'bcryptjs'
import { db, users, userIdentities } from '~/drizzle/db'
import { verifyBindingToken } from '~~/server/utils/oauth-token'
import { getServerDate } from '~~/server/utils/serverTime'
import { validateOAuthRegisterCredentials } from '~/utils/oauth-register'
import { isSecureRequest } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
import { createAuthSession } from '~~/server/utils/auth-session'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { resolveGradeClassError, REMARK_MAX_LENGTH } from '~~/server/utils/register-validation'
import { isGradeClassValid } from '~~/server/utils/grade-class-options'
import { getIdentityAvatarUrl } from '~~/server/utils/user-avatar'
import { verifyEmailCode } from '~~/server/utils/email-verification'
import { notifyRegistration } from '~~/server/utils/registration-notify'
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export default defineEventHandler(async (event) => {
// 数据库连接检查(在读取配置前执行,DB 故障时返回明确的 503)
try {
await db.select().from(users).limit(1)
} catch (error) {
console.error('Database connection error:', error)
throw createApiError(503, SERVER_ERROR_CODES.AUTH_DATABASE_UNAVAILABLE, '数据库服务暂时不可用')
}
// 检查是否允许 OAuth 注册
const config = await db.query.systemSettings.findFirst()
if (!config?.allowOAuthRegistration) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_OAUTH_REGISTER_DISABLED_BIND, '系统已关闭第三方账号注册功能,请登录现有账号进行绑定')
}
const body = await readBody(event)
const { password, confirmPassword } = body
const username = typeof body.username === 'string' ? body.username.trim() : ''
const name = typeof body.name === 'string' ? body.name.trim() : ''
const selectedGrade = typeof body.grade === 'string' ? body.grade.trim() : ''
const selectedClass = typeof body.class === 'string' ? body.class.trim() : ''
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const emailCode = typeof body.emailCode === 'string' ? body.emailCode.trim() : ''
const bindingToken = getCookie(event, 'binding-token')
// 邮箱必填由管理员开关控制(与本地注册同源):开启时邮箱必填;验证码在用户名检查后消费
const emailRequired = Boolean(config?.registerEmailRequired)
if (emailRequired && !email) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请填写邮箱地址')
}
if (email && !EMAIL_REGEX.test(email)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请输入有效的邮箱地址')
}
if (remark.length > REMARK_MAX_LENGTH) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `备注不能超过 ${REMARK_MAX_LENGTH} 个字符`)
}
if (!bindingToken) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_REGISTER_SESSION_EXPIRED, '注册会话已过期,请重新通过第三方登录发起')
}
let payload
try {
payload = verifyBindingToken(bindingToken)
} catch (e) {
deleteCookie(event, 'binding-token')
throw createApiError(400, SERVER_ERROR_CODES.AUTH_INVALID_REGISTER_TOKEN, '无效的注册令牌')
}
// 验证输入
if (!username || !name || !password || !confirmPassword) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_NAME_USERNAME_PASSWORD_REQUIRED, '姓名、用户名、密码不能为空')
}
const validationError = validateOAuthRegisterCredentials(username, password, confirmPassword)
if (validationError) {
throw createApiError(400, validationError.code, validationError.message)
}
const gradeClassError = resolveGradeClassError(
selectedGrade,
selectedClass,
Boolean(config?.registerRequiresGradeClass)
)
if (gradeClassError) {
throw createApiError(400, gradeClassError.code, gradeClassError.message)
}
if (selectedGrade && selectedClass) {
const valid = await isGradeClassValid(selectedGrade, selectedClass)
if (!valid) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_GRADE_CLASS_MUST_EXIST, '请选择系统内已存在的年级和班级')
}
}
// 检查用户名是否已存在
const existingUser = await db.query.users.findFirst({
where: (t, { eq }) => eq(t.username, username)
})
if (existingUser) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
}
// 检查OAuth身份是否已被绑定
const existingIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
})
if (existingIdentity) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_OAUTH_ALREADY_BOUND, '该第三方账号已被绑定,请直接登录或绑定到现有账户')
}
// 邮箱验证码在最后校验(前置校验通过后消费)
if (email && !(await verifyEmailCode(email, emailCode))) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_INVALID, '邮箱验证码无效或已过期,请重新获取')
}
try {
// 开事务创建用户和关联身份
const result = await db.transaction(async (tx) => {
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10)
const now = getServerDate()
const avatarUrl = getIdentityAvatarUrl({
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername,
avatar: payload.avatar
})
// 创建用户(onConflictDoNothing 兜底并发用户名竞态)
const insertedUser = (await tx
.insert(users)
.values({
username,
name,
grade: selectedGrade || null,
class: selectedClass || null,
password: hashedPassword,
role: 'USER',
status: config?.oauthRegisterRequiresApproval ? 'pending' : 'active',
remark: remark || null,
email: email || null,
emailVerified: email ? true : null,
createdAt: now,
updatedAt: now,
passwordChangedAt: now,
lastLogin: now,
forcePasswordChange: false,
avatarProvider: avatarUrl ? payload.provider : null,
avatarProviderUserId: avatarUrl ? payload.providerUserId : null
})
.onConflictDoNothing()
.returning({ id: users.id, tokenVersion: users.tokenVersion }))[0]
if (!insertedUser) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
}
// 关联OAuth身份
await tx.insert(userIdentities).values({
userId: insertedUser.id,
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername,
avatar: payload.avatar || null,
createdAt: now,
})
return insertedUser
})
// 清除绑定令牌
deleteCookie(event, 'binding-token')
// 注册通知(站内通知管理员 + 邮件;异步,失败不影响主流程)
void notifyRegistration(result.id, username, name, email, Boolean(config?.oauthRegisterRequiresApproval))
// 需要审核时:不签发登录态,等待管理员审核
if (config?.oauthRegisterRequiresApproval) {
return {
success: true,
pendingApproval: true
}
}
// 生成JWT令牌
const { token } = await createAuthSession(event, { id: result.id, role: 'USER', tokenVersion: result.tokenVersion }, payload.provider || 'oauth')
// 自动判断是否需要secure
const isSecure = isSecureRequest(event)
// 设置cookie
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
return {
success: true,
user: {
id: result.id,
username: username,
role: 'USER'
}
}
} catch (e: any) {
// 业务错误码(如用户名冲突 409)直接透传
if (e?.statusCode) throw e
console.error('OAuth register error:', e)
throw createApiError(500, SERVER_ERROR_CODES.AUTH_SYSTEM_ERROR, e.message || '注册失败,请稍后重试')
}
})
+235
View File
@@ -0,0 +1,235 @@
import bcrypt from 'bcryptjs'
import { eq, inArray } from 'drizzle-orm'
import { db, users } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { validateOAuthRegisterCredentials } from '~/utils/oauth-register'
import { isSecureRequest } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { getClientIP } from '~~/server/utils/ip-utils'
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
import { getServerDate, getServerTimestamp } from '~~/server/utils/serverTime'
import { verifyAndConsumeCaptcha } from '~~/server/utils/captcha'
import { resolveGradeClassError, REMARK_MAX_LENGTH } from '~~/server/utils/register-validation'
import { isGradeClassValid } from '~~/server/utils/grade-class-options'
import { verifyEmailCode } from '~~/server/utils/email-verification'
import { notifyRegistration } from '~~/server/utils/registration-notify'
const REGISTER_RATE_LIMIT = 5
const REGISTER_RATE_WINDOW_MS = 60 * 60 * 1000
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const clientIp = getClientIP(event)
// 数据库连接检查(在读取配置前执行,DB 故障时返回明确的 503 兜底)
try {
await db.select().from(users).limit(1)
} catch (error) {
console.error('Database connection error:', error)
throw createApiError(503, SERVER_ERROR_CODES.AUTH_DATABASE_UNAVAILABLE, '数据库服务暂时不可用')
}
const config = await db.query.systemSettings.findFirst()
// 开关:是否开放用户名密码注册
if (!config?.allowRegister) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_REGISTER_DISABLED, '系统未开放注册')
}
// 限流:按 IP 每小时最多 5 次注册请求
const limitResult = await checkDistributedRateLimit(`register:${clientIp}`, REGISTER_RATE_LIMIT, REGISTER_RATE_WINDOW_MS)
if (!limitResult.isAllowed) {
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
throw createApiError(429, SERVER_ERROR_CODES.AUTH_RATE_LIMITED_MINUTES, `注册请求过于频繁,请等待 ${waitMinutes} 分钟后再试`, { params: [waitMinutes] })
}
// 验证码:开启验证码服务时注册必须通过(图形验证码或 Turnstile)
const captchaEnabled = Boolean(config?.captchaEnabled)
const captchaProvider = config?.captchaProvider || 'graphic'
if (captchaEnabled) {
if (captchaProvider === 'turnstile') {
const turnstileSecretKey = config?.turnstileSecretKey || ''
const turnstileToken = body.turnstileToken
if (!turnstileSecretKey) {
throw createApiError(500, SERVER_ERROR_CODES.AUTH_CAPTCHA_SERVICE_UNAVAILABLE, '验证码服务配置错误,请联系管理员')
}
if (!turnstileToken) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_CAPTCHA_REQUIRED, '请完成人机验证', { captchaRequired: true, captchaProvider: 'turnstile' })
}
const verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const formData = new URLSearchParams()
formData.append('secret', turnstileSecretKey)
formData.append('response', turnstileToken)
formData.append('remoteip', clientIp)
try {
const result: any = await $fetch(verifyUrl, {
method: 'POST',
body: formData,
timeout: 5000
})
if (!result.success) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_CAPTCHA_FAILED_OR_EXPIRED, '人机验证失败或已过期,请重试', { captchaRequired: true, captchaProvider: 'turnstile' })
}
} catch (err: any) {
if (err.statusCode === 400) throw err
console.error('Turnstile verification error:', err)
throw createApiError(500, SERVER_ERROR_CODES.AUTH_CAPTCHA_SERVICE_UNAVAILABLE, '人机验证服务暂时不可用')
}
} else {
const captchaId = body.captchaId
const captchaInput = body.captchaInput
if (!captchaId || !captchaInput) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_IMAGE_CAPTCHA_REQUIRED, '请完成图形验证码', { captchaRequired: true, captchaProvider: 'graphic' })
}
const isValid = await verifyAndConsumeCaptcha(captchaId, captchaInput)
if (!isValid) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_CAPTCHA_FAILED_OR_EXPIRED, '验证码错误或已过期,请重新输入', { captchaRequired: true, captchaProvider: 'graphic' })
}
}
}
// 字段校验
const username = typeof body.username === 'string' ? body.username.trim() : ''
const name = typeof body.name === 'string' ? body.name.trim() : ''
const password = typeof body.password === 'string' ? body.password : ''
const confirmPassword = typeof body.confirmPassword === 'string' ? body.confirmPassword : ''
const selectedGrade = typeof body.grade === 'string' ? body.grade.trim() : ''
const selectedClass = typeof body.class === 'string' ? body.class.trim() : ''
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const emailCode = typeof body.emailCode === 'string' ? body.emailCode.trim() : ''
if (!username || !name || !password || !confirmPassword) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_NAME_USERNAME_PASSWORD_REQUIRED, '姓名、用户名、密码不能为空')
}
const validationError = validateOAuthRegisterCredentials(username, password, confirmPassword)
if (validationError) {
throw createApiError(400, validationError.code, validationError.message)
}
// 邮箱必填由管理员开关控制(需 SMTP 已配置方可开启):开启时邮箱必填
const emailRequired = Boolean(config?.registerEmailRequired)
if (emailRequired && !email) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请填写邮箱地址')
}
// 邮箱格式校验(验证码校验在用户名唯一性检查之后执行,避免失败即烧码)
if (email && !EMAIL_REGEX.test(email)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请输入有效的邮箱地址')
}
if (remark.length > REMARK_MAX_LENGTH) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `备注不能超过 ${REMARK_MAX_LENGTH} 个字符`)
}
const gradeClassError = resolveGradeClassError(
selectedGrade,
selectedClass,
Boolean(config?.registerRequiresGradeClass)
)
if (gradeClassError) {
throw createApiError(400, gradeClassError.code, gradeClassError.message)
}
if (selectedGrade && selectedClass) {
const valid = await isGradeClassValid(selectedGrade, selectedClass)
if (!valid) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_GRADE_CLASS_MUST_EXIST, '请选择系统内已存在的年级和班级')
}
}
// 用户名唯一性检查(预查 + 插入 onConflictDoNothing 兜底并发竞态)
const existingUser = await db.query.users.findFirst({
where: (t, { eq }) => eq(t.username, username)
})
if (existingUser) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
}
// 邮箱验证码在最后校验(此时用户名等前置校验均已通过,失败即消费不会误烧码)
if (email && !(await verifyEmailCode(email, emailCode))) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_INVALID, '邮箱验证码无效或已过期,请重新获取')
}
const requiresApproval = Boolean(config?.registerRequiresApproval)
try {
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10)
const now = getServerDate()
// 创建用户
const insertedUser = (await db
.insert(users)
.values({
username,
name,
grade: selectedGrade || null,
class: selectedClass || null,
password: hashedPassword,
role: 'USER',
status: requiresApproval ? 'pending' : 'active',
remark: remark || null,
email: email || null,
emailVerified: email ? true : null,
createdAt: now,
updatedAt: now,
passwordChangedAt: now,
lastLogin: now,
forcePasswordChange: false
})
.onConflictDoNothing()
.returning({ id: users.id, tokenVersion: users.tokenVersion }))[0]
if (!insertedUser) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
}
// 需要审核:不签发登录态,等待管理员审核
// 注册通知(异步,不阻塞主流程)
void notifyRegistration(insertedUser.id, username, name, email, requiresApproval)
if (requiresApproval) {
return {
success: true,
pendingApproval: true
}
}
// 无需审核:直接登录
const token = JWTEnhanced.generateToken(insertedUser.id, 'USER', insertedUser.tokenVersion)
const isSecure = isSecureRequest(event)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
return {
success: true,
user: {
id: insertedUser.id,
username: username,
role: 'USER'
}
}
} catch (e: any) {
// 业务错误码(如用户名冲突 409)直接透传
if (e?.statusCode) throw e
console.error('Register error:', e)
throw createApiError(500, SERVER_ERROR_CODES.AUTH_SYSTEM_ERROR, e.message || '注册失败,请稍后重试')
}
})
+106
View File
@@ -0,0 +1,106 @@
import crypto from 'node:crypto'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { updateUserPassword } from '~~/server/services/userService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { createApiError } from '~~/server/utils/apiError'
import { getPasswordPolicyViolation } from '~/utils/password-policy'
import {
PASSWORD_AUDIT_ACTIONS,
getPasswordAuditContext,
recordPasswordAudit
} from '~~/server/services/passwordSecurityService'
export default defineEventHandler(async (event) => {
const clientIP = getClientIP(event)
// IP 级别限流:每小时最多 10 次密码重置尝试
const rateLimitKey = `reset_password_ip:${clientIP}`
const limitResult = await checkDistributedRateLimit(rateLimitKey, 10, 60 * 60 * 1000)
if (!limitResult.isAllowed) {
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
throw createApiError(
429,
'AUTH_RESET_PASSWORD_TOO_MANY',
`重置密码尝试次数过多,请等待 ${waitMinutes} 分钟后再试`,
{ params: [waitMinutes] }
)
}
const body = await readBody<Record<string, unknown> | null>(event)
const token = typeof body?.token === 'string' ? body.token : ''
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
if (!token || !newPassword) {
throw createApiError(400, 'AUTH_INCOMPLETE_PARAMS', '参数不完整')
}
const policyViolation = getPasswordPolicyViolation(newPassword)
if (policyViolation) {
throw createApiError(400, policyViolation.code, policyViolation.message)
}
let auditUserId: number | null = null
let passwordUpdated = false
try {
// 验证并解码token
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'password_reset' || !decoded.userId || !decoded.hash) {
throw createApiError(400, 'AUTH_INVALID_RESET_LINK', '无效的重置链接')
}
// 获取最新用户信息
const userResult = await db.select().from(users).where(eq(users.id, decoded.userId)).limit(1)
const user = userResult[0]
if (!user) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
auditUserId = user.id
// 验证 hash 是否匹配当前密码的前10位
// 如果用户已经修改过密码,则 user.password 发生变化,旧 token 失效
// 使用恒定时间比较,避免通过时序侧信道泄露密码是否已被修改
const expectedHash = Buffer.from(user.password.substring(0, 10), 'utf8')
const providedHash = Buffer.from(typeof decoded.hash === 'string' ? decoded.hash : '', 'utf8')
if (
expectedHash.length !== providedHash.length ||
!crypto.timingSafeEqual(expectedHash, providedHash)
) {
throw createApiError(400, 'AUTH_RESET_LINK_INVALIDATED', '该重置链接已失效(密码已被修改)')
}
// 更新密码
await updateUserPassword(user.id, newPassword, {
expectedTokenVersion: user.tokenVersion,
auditContext: {
action: PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
...getPasswordAuditContext(event)
}
})
passwordUpdated = true
return { success: true, message: '密码重置成功,请使用新密码登录' }
} catch (error: any) {
if (auditUserId && !passwordUpdated) {
await recordPasswordAudit(
event,
auditUserId,
PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
false,
error.message || '重置密码失败'
)
}
if (error.name === 'TokenExpiredError') {
throw createApiError(400, 'AUTH_RESET_LINK_EXPIRED', '重置链接已过期,请重新申请')
}
if (error.statusCode) throw error
throw createApiError(400, 'AUTH_INVALID_RESET_LINK', '无效的重置链接')
}
})
@@ -0,0 +1,121 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { updateUserPassword } from '~~/server/services/userService'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { isSecureRequest } from '~~/server/utils/request-utils'
import { getInitialPasswordPolicyViolation } from '~/utils/password-policy'
import { getClientIP } from '~~/server/utils/ip-utils'
import { canSetInitialPassword } from '~~/server/utils/initial-password-policy'
import {
PASSWORD_AUDIT_ACTIONS,
consumePasswordRateLimit,
getPasswordAuditContext,
recordPasswordAudit
} from '~~/server/services/passwordSecurityService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED', '未授权')
}
const auditAction = PASSWORD_AUDIT_ACTIONS.INITIAL_PASSWORD
const body = await readBody<Record<string, unknown> | null>(event)
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
if (!newPassword) {
await recordPasswordAudit(event, user.id, auditAction, false, '新密码为空')
throw createApiError(400, 'AUTH_NEW_PASSWORD_REQUIRED', '新密码不能为空')
}
const clientIp = getClientIP(event)
const rateLimit = await consumePasswordRateLimit(user.id, clientIp, auditAction, 5)
if (!rateLimit.allowed) {
await recordPasswordAudit(event, user.id, auditAction, false, '操作频率超过限制')
setResponseHeader(event, 'Retry-After', String(rateLimit.retryAfterSeconds))
const retryAfterMinutes = Math.max(1, Math.ceil(rateLimit.retryAfterSeconds / 60))
throw createApiError(
429,
'AUTH_RATE_LIMITED_MINUTES',
`初始密码设置尝试过于频繁,请 ${retryAfterMinutes} 分钟后再试`,
{ params: [retryAfterMinutes] }
)
}
const policyViolation = getInitialPasswordPolicyViolation(newPassword)
if (policyViolation) {
await recordPasswordAudit(event, user.id, auditAction, false, policyViolation.message)
throw createApiError(400, policyViolation.code, policyViolation.message)
}
let passwordUpdated = false
try {
const currentUserResult = await db
.select({
password: users.password,
passwordChangedAt: users.passwordChangedAt,
tokenVersion: users.tokenVersion
})
.from(users)
.where(eq(users.id, user.id))
.limit(1)
const currentUser = currentUserResult[0]
if (!currentUser) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
// 已完成初始设置的账号必须通过需要旧密码的修改流程。
if (currentUser.passwordChangedAt) {
throw createApiError(400, 'AUTH_PASSWORD_ALREADY_SET', '您已经设置过密码,请使用修改密码功能')
}
if (!canSetInitialPassword(user, currentUser)) {
throw createApiError(
403,
'AUTH_INITIAL_PASSWORD_NOT_REQUIRED',
'当前账号不需要设置初始密码,请使用修改密码功能'
)
}
// OAuth 账号可能没有可用于比对的旧密码,不能把 null 传给 bcrypt。
if (currentUser.password && (await bcrypt.compare(newPassword, currentUser.password))) {
throw createApiError(400, 'AUTH_NEW_PASSWORD_SAME_AS_CURRENT', '新密码不能与当前密码相同')
}
const { passwordChangedAt, tokenVersion } = await updateUserPassword(user.id, newPassword, {
expectedTokenVersion: currentUser.tokenVersion,
auditContext: { action: auditAction, ...getPasswordAuditContext(event) }
})
passwordUpdated = true
const newToken = JWTEnhanced.generateToken(user.id, user.role, tokenVersion, event.context.authSessionId)
setCookie(event, 'auth-token', newToken, {
httpOnly: true,
secure: isSecureRequest(event),
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return {
success: true,
message: '初始密码设置成功',
passwordChangedAt
}
} catch (error: any) {
if (!passwordUpdated) {
await recordPasswordAudit(
event,
user.id,
auditAction,
false,
error.message || '初始密码设置失败'
)
}
if (error.statusCode) throw error
throw createApiError(500, 'AUTH_SYSTEM_ERROR', '初始密码设置失败,请稍后重试')
}
})
+98
View File
@@ -0,0 +1,98 @@
import { db, eq, and, users, userIdentities } from '~/drizzle/db'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { createApiError } from '~~/server/utils/apiError'
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
const body = await readBody(event)
const { provider, id } = body
if (!provider) {
throw createApiError(400, 'AUTH_MISSING_PROVIDER_PARAM', '缺少提供商参数')
}
const whereCondition = id
? and(
eq(userIdentities.id, id),
eq(userIdentities.userId, user.id),
eq(userIdentities.provider, provider)
)
: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, provider))
const rpID = provider === 'webauthn' ? getWebAuthnConfig(event).rpID : undefined
const removedIdentities = await db.transaction(async (tx) => {
const targetIdentities = await tx.query.userIdentities.findMany({
where: whereCondition,
columns: {
id: true,
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
const userRecord = await tx.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
avatarProvider: true,
avatarProviderUserId: true
}
})
const targetKeys = new Set(
targetIdentities.map((identity) => `${identity.provider}:${identity.providerUserId}`)
)
const isDeletingAvatarSource =
!!userRecord &&
targetIdentities.some(
(identity) =>
identity.provider === userRecord.avatarProvider &&
identity.providerUserId === userRecord.avatarProviderUserId
)
if (isDeletingAvatarSource) {
const allIdentities = await tx.query.userIdentities.findMany({
where: eq(userIdentities.userId, user.id),
columns: {
id: true,
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
const remainingIdentities = allIdentities.filter(
(identity) => !targetKeys.has(`${identity.provider}:${identity.providerUserId}`)
)
const fallback = resolveAvatarSource(userRecord, remainingIdentities)
await tx
.update(users)
.set({
avatarProvider: fallback?.provider ?? null,
avatarProviderUserId: fallback?.providerUserId ?? null
})
.where(eq(users.id, user.id))
}
return tx
.delete(userIdentities)
.where(whereCondition)
.returning({ credentialId: userIdentities.providerUserId })
})
const passkeyCleanup = rpID
? removedIdentities.map((identity) => ({
credentialId: identity.credentialId,
rpId: rpID
}))
: []
return { success: true, passkeyCleanup }
})
+77
View File
@@ -0,0 +1,77 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { resolveRequirePasswordChange } from '~~/server/utils/system-settings-helper'
import { getPasswordSetupState } from '~~/server/utils/initial-password-policy'
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
export default defineEventHandler(async (event) => {
const authUser = event.context.user
if (!authUser) {
throw createApiError(401, 'AUTH_TOKEN_MISSING', '未提供认证令牌')
}
const userId = authUser.id
// 用户资料始终从数据库获取,避免权限和绑定状态缓存过期。
const userResult = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
id: true,
username: true,
name: true,
grade: true,
class: true,
role: true,
password: true,
forcePasswordChange: true,
passwordChangedAt: true,
avatarProvider: true,
avatarProviderUserId: true
},
with: {
identities: {
columns: {
provider: true,
providerUsername: true,
providerUserId: true,
avatar: true,
createdAt: true
}
}
}
})
const dbUser = userResult || null
if (!dbUser) {
throw createApiError(401, 'USER_NOT_FOUND', '用户不存在')
}
// 与登录接口保持一致:按系统开关与用户状态计算强制改密及首次设置密码状态
const requirePasswordChange = await resolveRequirePasswordChange(dbUser)
const passwordSetupState = getPasswordSetupState(dbUser, requirePasswordChange)
// 构建返回的用户对象,只包含需要的字段
const avatarSource = resolveAvatarSource(dbUser, dbUser.identities || [])
const user = {
id: dbUser.id,
username: dbUser.username,
name: dbUser.name,
grade: dbUser.grade,
class: dbUser.class,
role: dbUser.role,
forcePasswordChange: dbUser.forcePasswordChange,
passwordChangedAt: dbUser.passwordChangedAt,
requirePasswordChange,
...passwordSetupState,
has2FA: dbUser.identities?.some((id: any) => id.provider === 'totp') || false,
avatar: avatarSource?.url ?? null
}
return {
user,
valid: true
}
})
@@ -0,0 +1,21 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const { rpID } = getWebAuthnConfig(event)
if (!rpID) {
throw createApiError(500, 'AUTH_WEBAUTHN_RP_ID_INVALID', 'WebAuthn RP ID 配置无效')
}
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
allowCredentials: []
})
setWebAuthnChallenge(event, options.challenge, 'authentication')
return options
})
@@ -0,0 +1,120 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, eq, and, userIdentities } from '~/drizzle/db'
import { defineEventHandler, setCookie } from 'h3'
import { createAuthSession } from '~~/server/utils/auth-session'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const challengeData = getWebAuthnChallenge(event)
if (!challengeData) {
throw createApiError(400, 'AUTH_CHALLENGE_EXPIRED', 'Challenge 已失效')
}
// 通过 credentialID 查找对应的用户身份
const credentialID = body.id
if (!credentialID) {
throw createApiError(400, 'AUTH_MISSING_CREDENTIAL_ID', '缺少 Credential ID')
}
// Challenge 只能使用一次,避免失败响应被重复提交。
clearWebAuthnChallenge(event)
const identity = await db.query.userIdentities.findFirst({
where: and(
eq(userIdentities.provider, 'webauthn'),
eq(userIdentities.providerUserId, credentialID)
),
with: { user: true }
})
if (!identity) {
throw createApiError(400, 'AUTH_PASSKEY_ACCOUNT_NOT_FOUND', '未找到该 Passkey 关联的账号')
}
const user = identity.user
// 检查用户账号状态
if (!user || user.status !== 'active') {
throw createApiError(403, 'AUTH_ACCOUNT_UNAVAILABLE_OR_RESTRICTED', '该账号不可用或已限制访问')
}
// 解析存储的凭证数据
if (!identity.providerUsername) {
throw createApiError(500, 'AUTH_CREDENTIAL_MISSING', '凭证数据缺失')
}
let credentialData
try {
credentialData = JSON.parse(identity.providerUsername)
} catch {
throw createApiError(500, 'AUTH_CREDENTIAL_CORRUPTED', '凭证数据损坏')
}
const { publicKey, counter } = credentialData
// 验证公钥存在
if (!publicKey) {
throw createApiError(500, 'AUTH_CREDENTIAL_MISSING_PUBLICKEY', '凭证数据缺失 publicKey')
}
const { rpID, origin } = getWebAuthnConfig(event)
if (!rpID) {
throw createApiError(500, 'AUTH_WEBAUTHN_RP_ID_INVALID', 'WebAuthn RP ID 配置无效')
}
// 构造 WebAuthn 凭证对象
const credential = {
id: credentialID,
publicKey: Buffer.from(publicKey, 'base64url'),
counter: Number(counter || 0),
transports: credentialData.transports
}
try {
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential,
requireUserVerification: true
})
if (verification.verified) {
const { newCounter, credentialDeviceType, credentialBackedUp } =
verification.authenticationInfo
credentialData.counter = newCounter
credentialData.credentialDeviceType = credentialDeviceType
credentialData.credentialBackedUp = credentialBackedUp
await db
.update(userIdentities)
.set({ providerUsername: JSON.stringify(credentialData) })
.where(eq(userIdentities.id, identity.id))
// 签发登录 Token
const { token } = await createAuthSession(event, identity.user, 'webauthn')
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return { success: true, redirect: '/' }
} else {
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', '验证失败')
}
} catch (error) {
// 透传已携带错误码的业务错误,避免被重新包装后丢失 code 通道。
if (error && typeof error === 'object' && 'statusCode' in error) throw error
console.error('WebAuthn 登录验证失败:', error)
const message = error instanceof Error ? error.message : '验证失败'
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', message)
}
})
@@ -0,0 +1,65 @@
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { isoUint8Array } from '@simplewebauthn/server/helpers'
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, eq, and, userIdentities } from '~/drizzle/db'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
// 获取该用户已绑定的 WebAuthn 凭证
const existingCredentials = await db.query.userIdentities.findMany({
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'webauthn')),
columns: {
providerUserId: true,
providerUsername: true
}
})
// 防止重复注册相同凭证,使用存储的实际 transports 信息
const excludeCredentials = existingCredentials.map((cred) => {
let transports: string[] | undefined
try {
if (cred.providerUsername) {
const data = JSON.parse(cred.providerUsername)
transports = data.transports
}
} catch (e) {
console.error('解析 WebAuthn 凭证数据失败:', e)
}
return {
id: cred.providerUserId,
type: 'public-key' as const,
transports
}
})
const { rpID } = getWebAuthnConfig(event)
const rpName = 'VoiceHub'
const userName = user.username || user.email || user.id.toString()
const userDisplayName = user.name || userName
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: isoUint8Array.fromUTF8String(user.id.toString()),
userName,
userDisplayName,
attestationType: 'none',
supportedAlgorithmIDs: [-7, -257],
excludeCredentials,
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
}
})
// 存储注册挑战到 Cookie
setWebAuthnChallenge(event, options.challenge, user.id.toString())
return options
})
@@ -0,0 +1,91 @@
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, userIdentities } from '~/drizzle/db'
import { createApiError } from '~~/server/utils/apiError'
const VALID_TRANSPORTS = ['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb']
function sanitizeTransports(transports: unknown): string[] {
if (!Array.isArray(transports)) return []
return transports.filter(
(t): t is string => typeof t === 'string' && VALID_TRANSPORTS.includes(t)
)
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
const body = await readBody(event)
const challengeData = getWebAuthnChallenge(event)
if (!challengeData || challengeData.userId !== user.id.toString()) {
throw createApiError(400, 'AUTH_CHALLENGE_INVALID', 'Challenge 已失效或不匹配')
}
// Challenge 只能使用一次,验证失败后也必须重新生成。
clearWebAuthnChallenge(event)
const { rpID, origin } = getWebAuthnConfig(event)
try {
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true
})
if (verification.verified && verification.registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo
// 提取凭证数据
const { id: credentialID, publicKey: credentialPublicKey, counter, transports } = credential
if (!credentialID || !credentialPublicKey) {
throw new Error('注册信息缺少凭证 ID 或公钥')
}
// 转换为 Base64URL 格式存储
const credentialIDBase64 = credentialID
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64url')
if (!publicKeyBase64) {
throw new Error('生成的公钥为空,注册失败')
}
// 构造凭证数据
const credentialData = {
label: body.label || 'WebAuthn 设备',
publicKey: publicKeyBase64,
counter: Number(counter),
transports: sanitizeTransports(transports),
credentialDeviceType,
credentialBackedUp
}
// 存储到数据库
await db.insert(userIdentities).values({
userId: user.id,
provider: 'webauthn',
providerUserId: credentialIDBase64,
providerUsername: JSON.stringify(credentialData),
createdAt: new Date()
})
return { success: true }
} else {
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', '验证失败')
}
} catch (error) {
// 透传已携带错误码的业务错误,避免被重新包装后丢失 code 通道。
if (error && typeof error === 'object' && 'statusCode' in error) throw error
console.error('WebAuthn 验证失败:', error)
const message = error instanceof Error ? error.message : '验证失败'
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', message)
}
})
+52
View File
@@ -0,0 +1,52 @@
import { db, eq, and, userIdentities, sql } from '~/drizzle/db'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
interface RenameRequestBody {
id: string
name: string
}
const body = await readBody<RenameRequestBody>(event)
const { id, name: rawName } = body
const name = typeof rawName === 'string' ? rawName.trim() : ''
if (!id || !name) {
throw createApiError(400, 'COMMON_INVALID_PARAMS', '参数错误')
}
if (name.length > 50) {
throw createApiError(400, 'AUTH_DEVICE_NAME_TOO_LONG', '设备名称过长')
}
// 查找对应的 Passkey
const identity = await db.query.userIdentities.findFirst({
where: and(
eq(userIdentities.id, id),
eq(userIdentities.userId, user.id),
eq(userIdentities.provider, 'webauthn')
)
})
if (!identity) {
throw createApiError(404, 'AUTH_DEVICE_NOT_FOUND', '未找到该设备')
}
try {
// 使用 jsonb_set 原子更新 label,避免覆盖 concurrent login 产生的 counter 更新
await db.update(userIdentities)
.set({
providerUsername: sql`jsonb_set(${userIdentities.providerUsername}::jsonb, '{label}', ${JSON.stringify(name)}::jsonb)::text`
})
.where(eq(userIdentities.id, id))
return { success: true }
} catch (e) {
console.error('更新设备名称失败:', e)
throw createApiError(500, 'AUTH_UPDATE_FAILED', '更新失败')
}
})