Files
ProxLedger/src/server/auth/sitzungspflicht.ts
T
florian.wildeandClaude Opus 5 2f45e33e20 feat(auth): Rollen, Benutzerverwaltung und Passkeys (#027, #028)
Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das
Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten
haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen -
ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen
selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests
pruefen das.

Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des
Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der
Vorgabe Techniker.

Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort
und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der
oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:46:29 +02:00

99 lines
3.9 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 { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "./rollen";
/**
* Verlangt eine gültige Sitzung und prüft sie gegen die Datenbank.
*
* `proxy.ts` hält Unangemeldete von den Seiten fern, aber Server Actions sind eigene
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
* verlassen (OWASP A01 – Broken Access Control).
*
* Rolle und Kundenbindung kommen **aus der Datenbank**, nicht aus dem Token: Eine geänderte
* Rolle wirkt damit sofort und nicht erst nach Ablauf der Sitzung.
*/
export interface AngemeldeterBenutzer extends GeleseneSitzung {
rolle: Rolle;
/** Nur bei der Rolle KUNDE gesetzt: der Kunde, dessen Daten sichtbar sind. */
kundeId: string | null;
}
/** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
export function sitzungBewerten(
sitzung: GeleseneSitzung | null,
benutzer: { aktiv: boolean; sitzungenUngueltigAb: Date | null } | null,
): { gueltig: true } | { gueltig: false; grund: string } {
if (!sitzung) return { gueltig: false, grund: "Bitte anmelden." };
if (!benutzer) return { gueltig: false, grund: "Dieses Konto gibt es nicht mehr." };
if (!benutzer.aktiv) return { gueltig: false, grund: "Dieses Konto ist gesperrt." };
if (benutzer.sitzungenUngueltigAb && sitzung.ausgestelltAm <= benutzer.sitzungenUngueltigAb) {
return { gueltig: false, grund: "Die Sitzung wurde beendet. Bitte neu anmelden." };
}
return { gueltig: true };
}
/**
* Liest die aktuelle Anmeldung, ohne weiterzuleiten – für Stellen, die auch ohne Sitzung
* funktionieren müssen (Worker, Skripte, Audit-Log). `cache` sorgt dafür, dass eine Anfrage
* die Datenbank nur einmal dafür befragt.
*/
export const angemeldet = cache(async (): Promise<AngemeldeterBenutzer | null> => {
let sitzung: GeleseneSitzung | null = null;
try {
sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
} catch {
// Außerhalb einer Anfrage gibt es keine Cookies – das ist kein Fehler.
return null;
}
if (!sitzung) return null;
const benutzer = await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId },
select: { aktiv: true, sitzungenUngueltigAb: true, rolle: true, kundeId: true },
});
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return null;
return { ...sitzung, rolle: benutzer.rolle, kundeId: benutzer.kundeId };
});
/**
* Verlangt eine Anmeldung und – falls angegeben – ein bestimmtes Recht.
* Ohne Anmeldung geht es zur Anmeldeseite, bei fehlendem Recht auf die Startseite.
*/
export async function sitzungErforderlich(recht?: Recht): Promise<AngemeldeterBenutzer> {
const benutzer = await angemeldet();
if (!benutzer) redirect("/login?fehler=Bitte+anmelden.");
if (recht && !darf(benutzer.rolle, recht)) {
redirect("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung.");
}
return benutzer;
}
/**
* Auf welchen Kunden die Sicht eingeschränkt ist – `null` heißt: alles sichtbar.
*
* Die Abfragen rufen das selbst auf, statt sich eine Einschränkung von der Seite reichen zu lassen.
* So kann keine Seite vergessen, sie weiterzugeben – der häufigste Weg, wie solche Sichten lecken.
*/
export async function sichtbarerKunde(): Promise<string | null> {
const benutzer = await angemeldet();
if (!benutzer || benutzer.rolle !== "KUNDE") return null;
// Ein Kundenkonto ohne Kundenbindung darf nichts sehen – nicht alles.
return benutzer.kundeId ?? "kein-kunde-zugeordnet";
}
/** Dürfen Einkaufspreise und Marge angezeigt werden? */
export async function darfEinkaufSehen(): Promise<boolean> {
const benutzer = await angemeldet();
return benutzer?.rolle !== "KUNDE";
}