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,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "@/lib/validierung";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
|
||||
/**
|
||||
* Einheitliche Fehlerantwort der REST-API – Web-GUI und CLI lesen dasselbe Format:
|
||||
* `{ fehler: { code, nachricht, hinweis?, felder? } }`.
|
||||
*/
|
||||
export type FehlerAntwort = {
|
||||
fehler: { code: string; nachricht: string; hinweis?: string; felder?: Record<string, string> };
|
||||
};
|
||||
|
||||
export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
|
||||
if (fehler instanceof AnwendungsFehler) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: {
|
||||
code: fehler.code,
|
||||
nachricht: fehler.message,
|
||||
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
|
||||
},
|
||||
},
|
||||
{ status: fehler.status },
|
||||
);
|
||||
}
|
||||
if (fehler instanceof ZodError) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of fehler.issues) {
|
||||
const pfad = problem.path.join(".") || "_";
|
||||
felder[pfad] ??= problem.message;
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: {
|
||||
code: "ungueltig",
|
||||
nachricht: "Die Eingaben sind nicht vollständig oder ungültig.",
|
||||
felder,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
logger.error({ err: fehler }, "Unerwarteter Fehler in der API");
|
||||
return NextResponse.json({ fehler: { code: "intern", nachricht: "Interner Fehler." } }, { status: 500 });
|
||||
}
|
||||
|
||||
/** Umhüllt einen Route Handler: wandelt geworfene Fehler in das einheitliche Format. */
|
||||
export function api<A extends unknown[]>(
|
||||
handler: (...argumente: A) => Promise<Response>,
|
||||
): (...argumente: A) => Promise<Response> {
|
||||
return async (...argumente: A) => {
|
||||
try {
|
||||
return await handler(...argumente);
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest einen JSON-Rumpf; ein kaputter Rumpf ist ein 400, kein 500. */
|
||||
export async function jsonLesen(anfrage: Request): Promise<unknown> {
|
||||
try {
|
||||
return await anfrage.json();
|
||||
} catch {
|
||||
throw new AnwendungsFehler(400, "kein_json", "Der Anfragerumpf ist kein gültiges JSON.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Wer was darf – die einzige Stelle. Seiten, Route Handler und Navigation fragen `darf()`;
|
||||
* niemand vergleicht selbst mit einer Rolle.
|
||||
*
|
||||
* Admin: Server, Katalog, Benutzer, Einstellungen. Operator: ansehen und ausrollen.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
lesen: ["ADMIN", "OPERATOR"],
|
||||
ausrollen: ["ADMIN", "OPERATOR"],
|
||||
vorlagenPflegen: ["ADMIN", "OPERATOR"],
|
||||
eigeneZugaenge: ["ADMIN", "OPERATOR"],
|
||||
serverVerwalten: ["ADMIN"],
|
||||
katalogVerwalten: ["ADMIN"],
|
||||
benutzerVerwalten: ["ADMIN"],
|
||||
einstellungenVerwalten: ["ADMIN"],
|
||||
protokollLesen: ["ADMIN"],
|
||||
} as const satisfies Record<string, readonly Rolle[]>;
|
||||
|
||||
export type Recht = keyof typeof RECHTE;
|
||||
|
||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import type { Quelle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { darf, type Recht } from "@/server/auth/rechte";
|
||||
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
|
||||
|
||||
/**
|
||||
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
|
||||
* Token. Jede Seite und jeder Route Handler prüft selbst – das Layout ist nur das zweite Netz,
|
||||
* weil Next es bei Navigation innerhalb desselben Layouts nicht neu rendert.
|
||||
*/
|
||||
export type Aufrufer = { benutzer: Benutzer; quelle: Quelle };
|
||||
|
||||
async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | null> {
|
||||
const sitzung = await sitzungLesen(jwt);
|
||||
if (!sitzung) return null;
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||
return benutzer?.aktiv ? benutzer : null;
|
||||
}
|
||||
|
||||
/** Für Server Components. */
|
||||
export async function aktuellerBenutzer(): Promise<Benutzer | null> {
|
||||
const speicher = await cookies();
|
||||
return await benutzerAusSitzung(speicher.get(SITZUNG_COOKIE)?.value);
|
||||
}
|
||||
|
||||
/** Für Seiten: leitet zur Anmeldung um oder zeigt „kein Recht“. */
|
||||
export async function seiteSchuetzen(recht: Recht, pfad: string): Promise<Benutzer> {
|
||||
const benutzer = await aktuellerBenutzer();
|
||||
if (!benutzer) redirect(`/anmelden?ziel=${encodeURIComponent(pfad)}`);
|
||||
if (!darf(benutzer.rolle, recht)) redirect("/kein-recht");
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
function cookieAusKopf(kopf: string | null, name: string): string | undefined {
|
||||
if (!kopf) return undefined;
|
||||
for (const teil of kopf.split(";")) {
|
||||
const [schluessel, ...wert] = teil.trim().split("=");
|
||||
if (schluessel === name) return decodeURIComponent(wert.join("="));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
|
||||
* landen hier, damit kein Handler zwei Wege kennen muss.
|
||||
*/
|
||||
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
|
||||
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
|
||||
return benutzer ? { benutzer, quelle: "WEB" } : null;
|
||||
}
|
||||
|
||||
export async function rechtErforderlich(anfrage: Request, recht: Recht): Promise<Aufrufer> {
|
||||
const aufrufer = await anfrageAufrufer(anfrage);
|
||||
if (!aufrufer) throw nichtAngemeldet();
|
||||
if (!darf(aufrufer.benutzer.rolle, recht)) throw keinRecht();
|
||||
return aufrufer;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Fehler, die bis zum Aufrufer durchgereicht werden dürfen. Alles andere wird als
|
||||
* „Interner Fehler“ gemeldet und nur ins Log geschrieben.
|
||||
*
|
||||
* `hinweis` sagt, was zu tun ist – Web und CLI zeigen ihn unter der Meldung an.
|
||||
*/
|
||||
export class AnwendungsFehler extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
nachricht: string,
|
||||
readonly hinweis?: string,
|
||||
) {
|
||||
super(nachricht);
|
||||
this.name = "AnwendungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export const nichtAngemeldet = () => new AnwendungsFehler(401, "nicht_angemeldet", "Nicht angemeldet.");
|
||||
|
||||
export const keinRecht = () =>
|
||||
new AnwendungsFehler(
|
||||
403,
|
||||
"kein_recht",
|
||||
"Dafür fehlt die Berechtigung.",
|
||||
"Ein Administrator kann die Rolle ändern.",
|
||||
);
|
||||
|
||||
export const nichtGefunden = (was: string) =>
|
||||
new AnwendungsFehler(404, "nicht_gefunden", `${was} gibt es nicht.`);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Quelle } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export type ProtokollEintrag = {
|
||||
benutzerId: string | null;
|
||||
quelle: Quelle;
|
||||
/** Lesbar und deutsch, z. B. „Server angelegt“. */
|
||||
aktion: string;
|
||||
/** Maschinenlesbar, z. B. „server:rz1“. */
|
||||
ziel?: string;
|
||||
details?: Prisma.InputJsonValue;
|
||||
ip?: string | null;
|
||||
};
|
||||
|
||||
type Client = Pick<typeof prisma, "protokoll">;
|
||||
|
||||
/** Schreibt einen Audit-Eintrag; in einer Transaktion mit `tx` als Client aufrufen. */
|
||||
export async function protokollieren(eintrag: ProtokollEintrag, client: Client = prisma): Promise<void> {
|
||||
await client.protokoll.create({
|
||||
data: {
|
||||
benutzerId: eintrag.benutzerId,
|
||||
quelle: eintrag.quelle,
|
||||
aktion: eintrag.aktion,
|
||||
ziel: eintrag.ziel ?? null,
|
||||
details: eintrag.details,
|
||||
ip: eintrag.ip ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user