import { PrismaAdapter } from "@next-auth/prisma-adapter"; import bcrypt from "bcryptjs"; import type { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { prisma } from "./prisma"; import { checkRateLimit, getRateLimitConfig } from "./rate-limit"; const MAX_LOGIN_ATTEMPTS = 5; const LOGIN_WINDOW_MINUTES = 15; const LOGIN_LOCK_MINUTES = 15; const getClientIp = (req: unknown) => { const headers = req && typeof req === "object" && "headers" in req ? (req as any).headers : null; if (!headers) return "unknown"; const forwarded = typeof headers.get === "function" ? headers.get("x-forwarded-for") || headers.get("x-real-ip") : headers["x-forwarded-for"] || headers["x-real-ip"]; if (!forwarded) return "unknown"; return String(forwarded).split(",")[0].trim() || "unknown"; }; const normalizeEmail = (value?: string | null) => (value || "").trim().toLowerCase(); const resetIfExpired = async (record: { id: string; attempts: number; lastAttempt: Date; }) => { const cutoff = Date.now() - LOGIN_WINDOW_MINUTES * 60 * 1000; if (record.lastAttempt.getTime() < cutoff) { await prisma.loginAttempt.update({ where: { id: record.id }, data: { attempts: 0, lockedUntil: null, lastAttempt: new Date() } }); return 0; } return record.attempts; }; const recordFailure = async (email: string, ip: string, attempts: number) => { const nextAttempts = attempts + 1; const lockedUntil = nextAttempts >= MAX_LOGIN_ATTEMPTS ? new Date(Date.now() + LOGIN_LOCK_MINUTES * 60 * 1000) : null; await prisma.loginAttempt.upsert({ where: { email_ip: { email, ip } }, update: { attempts: nextAttempts, lastAttempt: new Date(), lockedUntil }, create: { email, ip, attempts: nextAttempts, lastAttempt: new Date(), lockedUntil } }); }; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), session: { strategy: "jwt" }, providers: [ CredentialsProvider({ name: "credentials", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } }, async authorize(credentials, req) { if (!credentials?.email || !credentials.password) { return null; } const email = normalizeEmail(credentials.email); const ip = getClientIp(req); const rateConfig = getRateLimitConfig("RATE_LIMIT_LOGIN", 10); const rate = await checkRateLimit({ key: `login:${email}:${ip}`, limit: rateConfig.limit, windowMs: rateConfig.windowMs }); if (!rate.ok) { throw new Error("RATE_LIMIT"); } let attempt: { id: string; attempts: number; lastAttempt: Date; lockedUntil: Date | null } | null = null; try { attempt = await prisma.loginAttempt.findUnique({ where: { email_ip: { email, ip } } }); } catch { attempt = null; } if (attempt?.lockedUntil && attempt.lockedUntil > new Date()) { throw new Error("LOCKED"); } let attempts = 0; if (attempt) { try { attempts = await resetIfExpired(attempt); } catch { attempts = attempt.attempts; } } const user = await prisma.user.findUnique({ where: { email } }); if (!user) { try { await recordFailure(email, ip, attempts); } catch { // ignore } throw new Error("INVALID"); } const valid = await bcrypt.compare( credentials.password, user.passwordHash ); if (!valid) { try { await recordFailure(email, ip, attempts); } catch { // ignore } throw new Error("INVALID"); } if (user.status !== "ACTIVE") { throw new Error("PENDING"); } if (!user.emailVerified) { throw new Error("EMAIL_NOT_VERIFIED"); } if (attempt) { try { await prisma.loginAttempt.delete({ where: { id: attempt.id } }); } catch { // ignore } } return { id: user.id, email: user.email, name: user.name, role: user.role, status: user.status, emailVerified: user.emailVerified } as any; } }) ], callbacks: { async jwt({ token, user }) { if (user) { token.id = (user as any).id; token.role = (user as any).role; token.status = (user as any).status; token.emailVerified = (user as any).emailVerified; } return token; }, async session({ session, token }) { if (session.user) { (session.user as any).id = token.id; (session.user as any).role = token.role; (session.user as any).status = token.status; (session.user as any).emailVerified = token.emailVerified; } return session; } }, pages: { signIn: "/login" } }; export const isAdminEmail = (email?: string | null) => { if (!email) return false; const list = (process.env.ADMIN_EMAILS || "") .split(",") .map((entry) => entry.trim().toLowerCase()) .filter(Boolean); return list.includes(email.toLowerCase()); }; export const isSuperAdminEmail = (email?: string | null) => { if (!email) return false; const list = (process.env.SUPERADMIN_EMAILS || "") .split(",") .map((entry) => entry.trim().toLowerCase()) .filter(Boolean); return list.includes(email.toLowerCase()); };