import NextAuth, { type DefaultSession } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { z } from "zod"; import { ACTIVATE_STATUSES } from "@/lib/constants"; import { logger } from "@/lib/logger"; const AUTH_CTX = "auth"; type AttemptRecord = { count: number; until: number }; const loginAttempts = new Map(); const MAX_ATTEMPTS = 5; const LOCKOUT_MS = 15 * 60 * 1000; function isLockedOut(adminName: string): boolean { const record = loginAttempts.get(adminName); if (!record || record.count < MAX_ATTEMPTS) return false; if (record.until > Date.now()) return true; loginAttempts.delete(adminName); return false; } function recordFailure(adminName: string): void { const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 }; record.count += 1; if (record.count >= MAX_ATTEMPTS) { record.until = Date.now() + LOCKOUT_MS; } loginAttempts.set(adminName, record); } function clearAttempts(adminName: string): void { loginAttempts.delete(adminName); } const credentialsSchema = z.object({ adminName: z.string().trim().min(1), password: z.string().min(1), }); type AdminAuthRow = { AdminID: bigint | number; Name: string; Email: string; AccountType: bigint | number; ActivateStatus: bigint | number; }; declare module "next-auth" { interface Session { user: { adminID: number; accountType: number; activateStatus: number; } & DefaultSession["user"]; } interface User { accountType: number; activateStatus: number; } } declare module "next-auth/jwt" { interface JWT { accountType?: number; activateStatus?: number; } } const appEnv = process.env.APP_ENV ?? "local"; const isSecure = process.env.NODE_ENV === "production"; const sessionCookieName = `chocoadmin-${appEnv}.session-token`; export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ pages: { signIn: "/login", }, session: { strategy: "jwt", maxAge: 8 * 60 * 60, }, cookies: { sessionToken: { name: sessionCookieName, options: { httpOnly: true, sameSite: "lax" as const, path: "/", secure: isSecure, }, }, }, providers: [ Credentials({ credentials: { adminName: { label: "관리자 아이디", type: "text" }, password: { label: "암호", type: "password" }, }, async authorize(credentials) { const parsedCredentials = credentialsSchema.safeParse(credentials); if (!parsedCredentials.success) { return null; } const { adminName, password } = parsedCredentials.data; if (isLockedOut(adminName)) { logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName }); return null; } const { db } = await import("@/lib/db"); const admins = await db.$queryRaw` SELECT AdminID, Name, Email, AccountType, ActivateStatus FROM admin WHERE Name = ${adminName} AND Password = PASSWORD(${password}) LIMIT 1 `; const admin = admins[0]; const adminID = Number(admin?.AdminID); const accountType = Number(admin?.AccountType); const activateStatus = Number(admin?.ActivateStatus); if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) { recordFailure(adminName); logger.warn(AUTH_CTX, "login failed", { adminName }); return null; } clearAttempts(adminName); logger.info(AUTH_CTX, "login success", { adminName, adminID }); return { id: String(adminID), name: admin.Name, email: admin.Email, accountType, activateStatus, }; }, }), ], callbacks: { jwt({ token, user }) { if (user) { token.accountType = user.accountType; token.activateStatus = user.activateStatus; } return token; }, session({ session, token }) { return { ...session, user: { ...session.user, adminID: Number(token.sub), accountType: Number(token.accountType), activateStatus: Number(token.activateStatus), }, }; }, }, });