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>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
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.
|
||||
@@ -10,9 +13,16 @@ import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzu
|
||||
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
|
||||
* verlassen (OWASP A01 – Broken Access Control).
|
||||
*
|
||||
* Zusätzlich wirkt hier der Widerruf: Ein gesperrtes Konto oder ein zurückgesetztes
|
||||
* Passwort beendet laufende Sitzungen sofort, statt bis zum Ablauf des Tokens zu warten.
|
||||
* 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,
|
||||
@@ -27,18 +37,62 @@ export function sitzungBewerten(
|
||||
return { gueltig: true };
|
||||
}
|
||||
|
||||
export async function sitzungErforderlich(): Promise<GeleseneSitzung> {
|
||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
/**
|
||||
* 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 = sitzung
|
||||
? await prisma.benutzer.findUnique({
|
||||
where: { id: sitzung.benutzerId },
|
||||
select: { aktiv: true, sitzungenUngueltigAb: true },
|
||||
})
|
||||
: null;
|
||||
const benutzer = await prisma.benutzer.findUnique({
|
||||
where: { id: sitzung.benutzerId },
|
||||
select: { aktiv: true, sitzungenUngueltigAb: true, rolle: true, kundeId: true },
|
||||
});
|
||||
|
||||
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||
if (!urteil.gueltig) redirect(`/login?fehler=${encodeURIComponent(urteil.grund)}`);
|
||||
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return null;
|
||||
|
||||
return sitzung as GeleseneSitzung;
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user