Aktueller Stand
This commit is contained in:
132
lib/auth.ts
132
lib/auth.ts
@@ -4,6 +4,61 @@ import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
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" },
|
||||
@@ -14,17 +69,46 @@ export const authOptions: NextAuthOptions = {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials?.email || !credentials.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const email = normalizeEmail(credentials.email);
|
||||
const ip = getClientIp(req);
|
||||
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: credentials.email }
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
try {
|
||||
await recordFailure(email, ip, attempts);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error("INVALID");
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(
|
||||
@@ -33,14 +117,37 @@ export const authOptions: NextAuthOptions = {
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
return null;
|
||||
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
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
emailVerified: user.emailVerified
|
||||
} as any;
|
||||
}
|
||||
})
|
||||
@@ -48,13 +155,19 @@ export const authOptions: NextAuthOptions = {
|
||||
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;
|
||||
}
|
||||
@@ -72,3 +185,12 @@ export const isAdminEmail = (email?: string | null) => {
|
||||
.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());
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user