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 }, }) } }, }, })