Files
proxdeploy/src/server/services/anmeldung.ts
T
florian.wildeandClaude Opus 5.5 871bb3a77c 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>
2026-10-12 09:23:41 +02:00

106 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}