Aktueller Stand

This commit is contained in:
2026-01-15 16:24:09 +01:00
parent 5d2630a02f
commit 46eae2a2a9
70 changed files with 7866 additions and 447 deletions

View File

@@ -7,9 +7,25 @@ export async function requireSession() {
if (!session?.user?.email) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
if (session.user.status && session.user.status !== "ACTIVE") {
return {
session: null,
response: NextResponse.json({ error: "Account nicht freigeschaltet." }, { status: 403 })
};
}
if (session.user.emailVerified === false) {
return {
session: null,
response: NextResponse.json({ error: "E-Mail nicht verifiziert." }, { status: 403 })
};
}
return { session, response: null };
}
export function isAdminSession(session: { user?: { role?: string } } | null) {
return session?.user?.role === "ADMIN";
return session?.user?.role === "ADMIN" || session?.user?.role === "SUPERADMIN";
}
export function isSuperAdminSession(session: { user?: { role?: string } } | null) {
return session?.user?.role === "SUPERADMIN";
}

View File

@@ -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());
};

40
lib/mailer.ts Normal file
View File

@@ -0,0 +1,40 @@
import nodemailer from "nodemailer";
type MailPayload = {
to: string;
subject: string;
text: string;
};
const hasSmtpConfig = () =>
Boolean(
process.env.SMTP_HOST &&
process.env.SMTP_PORT &&
process.env.SMTP_USER &&
process.env.SMTP_PASS
);
export async function sendMail(payload: MailPayload) {
if (!hasSmtpConfig()) {
// Fallback for dev: log instead of sending.
console.log("[mail]", payload.subject, payload.text);
return;
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: payload.to,
subject: payload.subject,
text: payload.text
});
}