207 lines
5.7 KiB
TypeScript
207 lines
5.7 KiB
TypeScript
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());
|
|
};
|