- Update src/auth.ts auth configuration - Update src/env.mjs environment variable validation - Update src/i18n/request.ts locale handling - Update src/proxy.ts middleware - Update src/next-auth.d.ts type declarations - Update package.json and package-lock.json dependencies
162 lines
6.0 KiB
TypeScript
162 lines
6.0 KiB
TypeScript
import NextAuth from "next-auth"
|
||
import Credentials from "next-auth/providers/credentials"
|
||
import { resolvePermissions } from "@/shared/lib/permissions"
|
||
import { isRole } from "@/shared/types/permissions"
|
||
import { logLoginEvent } from "@/shared/lib/login-logger"
|
||
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
|
||
import {
|
||
encodePermissionsBitmap,
|
||
decodePermissionsBitmap,
|
||
} from "@/shared/lib/permission-bitmap"
|
||
import { trackAuthEvent } from "@/shared/lib/track-event"
|
||
|
||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||
trustHost: true,
|
||
secret: process.env.NEXTAUTH_SECRET,
|
||
session: { strategy: "jwt" },
|
||
pages: { signIn: "/login" },
|
||
providers: [
|
||
Credentials({
|
||
credentials: {
|
||
email: { label: "Email", type: "email" },
|
||
password: { label: "Password", type: "password" },
|
||
totpCode: { label: "2FA Code", type: "text" },
|
||
},
|
||
// audit-P1-3 重构:authorize 回调原混合 7 类职责(107 行),
|
||
// 已抽取至 modules/auth/services/login-service.ts 的 authenticateUser 函数。
|
||
// 此处为薄包装,仅负责动态 import(避免 server-only 模块在模块评估期加载)
|
||
// 与委托调用。所有登录逻辑(速率限制/账户锁定/密码校验/2FA/角色加载)均在
|
||
// login-service 中实现,可独立测试。
|
||
authorize: async (credentials) => {
|
||
const { authenticateUser } = await import("@/modules/auth/services/login-service")
|
||
return authenticateUser(credentials)
|
||
},
|
||
}),
|
||
],
|
||
callbacks: {
|
||
jwt: async ({ token, user }) => {
|
||
// P0-5 修复:User 接口已在 next-auth.d.ts 中扩展了 role/roles 字段,
|
||
// 无需 `as` 断言即可安全访问。
|
||
if (user && user.id) {
|
||
token.id = user.id
|
||
token.role = normalizeRole(user.role)
|
||
token.name = user.name ?? undefined
|
||
// Store all roles (not just primary) and resolved permissions
|
||
const allRoles = (user.roles ?? [user.role ?? "student"]).filter(isRole)
|
||
token.roles = allRoles
|
||
// audit-P1-7:JWT 中仅存位图(~14 字符),不存数组(~1.1KB)。
|
||
// Session callback 解码还原为 Permission[] 供业务使用。
|
||
const permissions = await resolvePermissions(allRoles)
|
||
token.permissionsBitmap = encodePermissionsBitmap(permissions)
|
||
// Onboarding status is resolved from DB below; default to false on first login
|
||
token.onboarded = false
|
||
}
|
||
|
||
// Refresh roles/permissions from DB on each JWT refresh
|
||
const userId = String(token.id ?? "").trim()
|
||
if (userId) {
|
||
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||
import("drizzle-orm"),
|
||
import("@/shared/db"),
|
||
import("@/shared/db/schema"),
|
||
])
|
||
|
||
const [fresh, roleRows] = await Promise.all([
|
||
db.query.users.findFirst({
|
||
where: eq(users.id, userId),
|
||
columns: { name: true, onboardedAt: true },
|
||
}),
|
||
db
|
||
.select({ name: roles.name })
|
||
.from(usersToRoles)
|
||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||
.where(eq(usersToRoles.userId, userId)),
|
||
])
|
||
|
||
if (fresh) {
|
||
const allRoles = roleRows.map((r) => r.name).filter(isRole)
|
||
token.role = resolvePrimaryRole(allRoles)
|
||
token.name = fresh.name ?? token.name
|
||
token.roles = allRoles
|
||
// audit-P1-7:刷新位图,不再写 token.permissions 数组
|
||
const permissions = await resolvePermissions(allRoles)
|
||
token.permissionsBitmap = encodePermissionsBitmap(permissions)
|
||
token.onboarded = Boolean(fresh.onboardedAt)
|
||
}
|
||
}
|
||
|
||
return token
|
||
},
|
||
session: async ({ session, token }) => {
|
||
// audit-P1-7:session.user.permissions 由位图解码得到,
|
||
// 业务代码(getAuthContext / usePermission)无需感知位图存在。
|
||
if (session.user) {
|
||
session.user.id = String(token.id ?? "")
|
||
session.user.role = normalizeRole(token.role)
|
||
session.user.roles = (token.roles ?? []).filter(isRole)
|
||
session.user.permissions = decodePermissionsBitmap(
|
||
token.permissionsBitmap ?? "",
|
||
)
|
||
session.user.onboarded = Boolean(token.onboarded)
|
||
if (typeof token.name === "string") {
|
||
session.user.name = token.name
|
||
}
|
||
}
|
||
return session
|
||
},
|
||
},
|
||
events: {
|
||
async signIn({ user }) {
|
||
await logLoginEvent({
|
||
userId: user.id,
|
||
userEmail: user.email ?? "",
|
||
action: "signin",
|
||
status: "success",
|
||
})
|
||
// audit-P1-9:登录成功埋点(用于登录成功率、异常登录地理/设备告警)
|
||
// 非阻塞:trackEvent 内部已吞掉异常
|
||
await trackAuthEvent("auth.signin_success", {
|
||
userId: user.id,
|
||
properties: {
|
||
// user.role 是单一主角色,user.roles 是全部角色(可能未填充)
|
||
roles: (user.roles ?? (user.role ? [user.role] : [])),
|
||
},
|
||
})
|
||
},
|
||
async signOut(message) {
|
||
// P0-5 修复:使用 `in` 操作符对联合类型进行类型收窄,替代 `as` 断言。
|
||
// NextAuth v5 signOut 事件 message 为联合类型:
|
||
// { session: ... } | { token: JWT }
|
||
// JWT 策略下实际收到 { token: JWT },其中 JWT 已在 next-auth.d.ts 扩展。
|
||
let userId = ""
|
||
let userEmail = ""
|
||
let roles: string[] = []
|
||
|
||
if ("token" in message) {
|
||
const token = message.token
|
||
if (token) {
|
||
userId = String(token.id ?? "")
|
||
userEmail = String(token.email ?? "")
|
||
roles = (token.roles ?? []).filter(isRole)
|
||
}
|
||
}
|
||
|
||
if (userEmail) {
|
||
await logLoginEvent({
|
||
userId: userId || undefined,
|
||
userEmail,
|
||
action: "signout",
|
||
status: "success",
|
||
})
|
||
}
|
||
// audit-P1-9:登出埋点
|
||
if (userId) {
|
||
await trackAuthEvent("auth.signout", {
|
||
userId,
|
||
properties: { roles },
|
||
})
|
||
}
|
||
},
|
||
},
|
||
})
|