Files

171 lines
4.2 KiB
TypeScript

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<string, AttemptRecord>();
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<AdminAuthRow[]>`
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),
},
};
},
},
});