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:
2026-10-12 09:23:41 +02:00
co-authored by Claude Opus 5.5
parent c3211d0fe2
commit 871bb3a77c
75 changed files with 10155 additions and 15 deletions
+105
View File
@@ -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");
}