feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens
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>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user