import { compare } from "bcryptjs" import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" import { eq } from "drizzle-orm" import { resolvePermissions } from "@/shared/lib/permissions" import { isRole } from "@/shared/types/permissions" import { logLoginEvent } from "@/shared/lib/login-logger" import { PASSWORD_RULES, isAccountLocked, } from "@/shared/lib/password-policy" import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit" import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils" import { resolveClientIp } from "@/shared/lib/http-utils" import { getOrCreatePasswordSecurity, recordFailedLogin, resetFailedLogin, } from "@/shared/lib/password-security-service" import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils" 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" }, }, authorize: async (credentials) => { const email = String(credentials?.email ?? "").trim().toLowerCase() const password = String(credentials?.password ?? "") const totpCode = String(credentials?.totpCode ?? "").trim() if (!email || !password) return null // Rate limit by IP + email to slow brute-force attempts const clientIp = await resolveClientIp() const loginLimitKey = rateLimitKey("login", `${clientIp}:${email}`) const limit = rateLimit({ key: loginLimitKey, ...RATE_LIMIT_RULES.LOGIN, }) if (!limit.success) { await logLoginEvent({ userEmail: email, action: "signin", status: "failure", errorMessage: "Rate limit exceeded", }) return null } const [{ db }, { users, roles, usersToRoles, passwordSecurity }] = await Promise.all([ import("@/shared/db"), import("@/shared/db/schema"), ]) const user = await db.query.users.findFirst({ where: eq(users.email, email), }) if (!user) return null // Account lockout check const security = await getOrCreatePasswordSecurity(db, passwordSecurity, user.id) const lastFailedAt = security.lockedUntil ? new Date(security.lockedUntil.getTime() - PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000) : null if (isAccountLocked(security.failedLoginAttempts, lastFailedAt)) { await logLoginEvent({ userId: user.id, userEmail: email, action: "signin", status: "failure", errorMessage: "Account locked", }) return null } const storedPassword = user.password ?? null if (!storedPassword) return null const normalizedPassword = normalizeBcryptHash(storedPassword) if (!normalizedPassword.startsWith("$2")) return null const ok = await compare(password, normalizedPassword) if (!ok) { await recordFailedLogin(db, passwordSecurity, user.id) await logLoginEvent({ userId: user.id, userEmail: email, action: "signin", status: "failure", errorMessage: "Invalid credentials", }) return null } // Successful login: reset counters and rate limit await resetFailedLogin(db, passwordSecurity, user.id) resetRateLimit(loginLimitKey) // 2FA verification (if user has enabled it) const { verifyTwoFactorForLogin } = await import("@/modules/settings/actions-security") const twoFactorResult = await verifyTwoFactorForLogin({ userId: user.id, token: totpCode || undefined, }) if (twoFactorResult.required && !twoFactorResult.valid) { await logLoginEvent({ userId: user.id, userEmail: email, action: "signin", status: "failure", errorMessage: totpCode ? "Invalid 2FA code" : "2FA required but not provided", }) return null } const roleRows = await db .select({ name: roles.name }) .from(usersToRoles) .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .where(eq(usersToRoles.userId, user.id)) const roleNames = roleRows.map((r) => r.name) const resolvedRole = resolvePrimaryRole(roleNames) return { id: user.id, name: user.name ?? undefined, email: user.email, role: resolvedRole, roles: roleNames, } }, }), ], callbacks: { jwt: async ({ token, user }) => { if (user) { const u = user as { id: string; role?: string; roles?: string[]; name?: string } token.id = u.id token.role = normalizeRole(u.role) token.name = u.name ?? undefined // Store all roles (not just primary) and resolved permissions const allRoles = (u.roles ?? [u.role ?? "student"]).filter(isRole) token.roles = allRoles token.permissions = resolvePermissions(allRoles) // 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 token.permissions = resolvePermissions(allRoles) token.onboarded = Boolean(fresh.onboardedAt) } } return token }, session: async ({ session, token }) => { 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 = (token.permissions ?? []) as typeof token.permissions 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", }) }, async signOut(message) { // NextAuth v5 signOut event receives the session/token info const userId = (message as { userId?: string })?.userId ?? (message as { token?: { id?: string } })?.token?.id ?? "" const userEmail = (message as { token?: { email?: string } })?.token?.email ?? (message as { session?: { user?: { email?: string } } })?.session?.user?.email ?? "" if (userEmail) { await logLoginEvent({ userId: userId || undefined, userEmail, action: "signout", status: "success", }) } }, }, })