Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus Laufzettel übernommen, weil dort derselbe Stack erprobt ist. Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das helle Theme setzt dieselben Namen neu. Amber und Rot sind dort abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide Themes gegen 4,5:1. Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank, Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf 1440 px und 390 px. Closes #001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
import type { Benutzer } from "@/generated/prisma/client";
|
||
import { prisma } from "@/lib/db";
|
||
import { logger } from "@/lib/logger";
|
||
import { attrappenPruefung, passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||
import { AnwendungsFehler } from "@/server/fehler";
|
||
import { protokollieren } from "@/server/services/protokoll";
|
||
|
||
/**
|
||
* Sperre gegen Durchprobieren: höchstens 10 Fehlversuche je Benutzername und Adresse in 15 min.
|
||
* Bewusst im Speicher: ProxDeploy läuft als eine Instanz, und ein Neustart, der die Zähler
|
||
* leert, ist für einen Angreifer kein Gewinn – er müsste den Dienst dafür neu starten können.
|
||
*/
|
||
const FENSTER_MS = 15 * 60 * 1000;
|
||
const HOECHSTENS = 10;
|
||
const fehlversuche = new Map<string, number[]>();
|
||
|
||
function versucheZaehlen(schluessel: string, jetzt: number): number {
|
||
const liste = (fehlversuche.get(schluessel) ?? []).filter((t) => jetzt - t < FENSTER_MS);
|
||
fehlversuche.set(schluessel, liste);
|
||
return liste.length;
|
||
}
|
||
|
||
/** Nur für Tests. */
|
||
export function sperrenZuruecksetzen(): void {
|
||
fehlversuche.clear();
|
||
}
|
||
|
||
export async function mitPasswortAnmelden(
|
||
benutzername: string,
|
||
passwort: string,
|
||
ip: string | null,
|
||
): Promise<Benutzer> {
|
||
const name = benutzername.trim().toLowerCase();
|
||
const schluessel = `${name}|${ip ?? "-"}`;
|
||
const jetzt = Date.now();
|
||
if (versucheZaehlen(schluessel, jetzt) >= HOECHSTENS) {
|
||
throw new AnwendungsFehler(
|
||
429,
|
||
"zu_viele_versuche",
|
||
"Zu viele Fehlversuche.",
|
||
"In 15 Minuten erneut versuchen.",
|
||
);
|
||
}
|
||
|
||
const benutzer = await prisma.benutzer.findUnique({ where: { benutzername: name } });
|
||
const passt =
|
||
benutzer?.passwortHash && benutzer.aktiv
|
||
? await passwortPruefen(benutzer.passwortHash, passwort)
|
||
: await attrappenPruefung(passwort);
|
||
|
||
if (!benutzer || !passt) {
|
||
fehlversuche.get(schluessel)?.push(jetzt);
|
||
await protokollieren({
|
||
benutzerId: benutzer?.id ?? null,
|
||
quelle: "WEB",
|
||
aktion: "Anmeldung fehlgeschlagen",
|
||
ziel: `benutzer:${name}`,
|
||
ip,
|
||
});
|
||
// Eine Meldung für alle Fälle – sie verrät nicht, ob es den Benutzer gibt.
|
||
throw new AnwendungsFehler(401, "anmeldung_fehlgeschlagen", "Benutzername oder Passwort ist falsch.");
|
||
}
|
||
|
||
fehlversuche.delete(schluessel);
|
||
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
|
||
return benutzer;
|
||
}
|
||
|
||
/**
|
||
* Legt beim ersten Start den Administrator aus ADMIN_BENUTZER/ADMIN_PASSWORT an – nur solange die
|
||
* Tabelle leer ist. Danach sind die Variablen wirkungslos, damit ein vergessener Eintrag in der
|
||
* .env kein zweites Hintertürchen ist.
|
||
*/
|
||
export async function ersterAdminFallsLeer(): Promise<void> {
|
||
if ((await prisma.benutzer.count()) > 0) return;
|
||
const benutzername = process.env["ADMIN_BENUTZER"]?.trim().toLowerCase() || "admin";
|
||
const passwort = process.env["ADMIN_PASSWORT"] ?? "";
|
||
if (passwort.length < PASSWORT_MINDESTLAENGE) {
|
||
logger.warn(
|
||
`Es gibt noch keinen Benutzer. ADMIN_PASSWORT setzen (mindestens ${PASSWORT_MINDESTLAENGE} Zeichen) und neu starten.`,
|
||
);
|
||
return;
|
||
}
|
||
let benutzer: Benutzer;
|
||
try {
|
||
benutzer = await prisma.benutzer.create({
|
||
data: {
|
||
benutzername,
|
||
anzeigename: "Administrator",
|
||
rolle: "ADMIN",
|
||
passwortHash: await passwortHashen(passwort),
|
||
},
|
||
});
|
||
} catch (fehler) {
|
||
// Ein zweiter Prozess war schneller (app und worker starten gleichzeitig) – das ist in Ordnung.
|
||
if ((fehler as { code?: string }).code === "P2002") return;
|
||
throw fehler;
|
||
}
|
||
await protokollieren({
|
||
benutzerId: benutzer.id,
|
||
quelle: "SYSTEM",
|
||
aktion: "Erster Administrator angelegt",
|
||
});
|
||
logger.info({ benutzername }, "Erster Administrator angelegt");
|
||
}
|