- Update src/auth.ts auth configuration - Update src/env.mjs environment variable validation - Update exam-data and question-data mocks for testing
235 lines
8.0 KiB
TypeScript
235 lines
8.0 KiB
TypeScript
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",
|
|
})
|
|
}
|
|
},
|
|
},
|
|
})
|