Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery, PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig, und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen — eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht mit gültigem Cookie noch zwölf Stunden weiter. Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose, wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben. Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei Bedingungen. Browser entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim Verarbeiten einer URL, übrig bleibt "//fremd.example" und damit ein fremder Host — genau das Phishing, das die Prüfung abwehren sollte. Sie löst das Ziel jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft. Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive Administrator konnte sich selbst herabstufen. Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass scrollWidth es zeigen konnte. 274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
import { cache } from "react";
|
||
import { cookies } from "next/headers";
|
||
import { redirect } from "next/navigation";
|
||
import type { Benutzer } from "@/generated/prisma/client";
|
||
import { prisma } from "@/lib/db";
|
||
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||
import { darf, type Recht } from "@/server/auth/rollen";
|
||
|
||
/**
|
||
* Wer ist angemeldet und was darf er (#007).
|
||
*
|
||
* Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht
|
||
* aus dem Cookie. Das kostet eine Abfrage und ist es wert: eine entzogene Rolle
|
||
* greift sofort, und ein deaktiviertes Konto kommt nicht mit einem gueltigen
|
||
* Cookie noch zwoelf Stunden weiter.
|
||
*/
|
||
|
||
export class RechteFehler extends Error {
|
||
constructor(nachricht: string) {
|
||
super(nachricht);
|
||
this.name = "RechteFehler";
|
||
}
|
||
}
|
||
|
||
export type Urteil =
|
||
| { readonly gueltig: true }
|
||
| { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" };
|
||
|
||
/**
|
||
* Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next,
|
||
* damit sie sich einzeln testen laesst.
|
||
*/
|
||
export function sitzungBewerten(
|
||
sitzung: Sitzung,
|
||
benutzer: Pick<Benutzer, "id" | "aktiv" | "sitzungenUngueltigAb"> | null,
|
||
): Urteil {
|
||
if (!benutzer || benutzer.id !== sitzung.benutzerId) {
|
||
return { gueltig: false, grund: "unbekannt" };
|
||
}
|
||
if (!benutzer.aktiv) {
|
||
return { gueltig: false, grund: "deaktiviert" };
|
||
}
|
||
if (benutzer.sitzungenUngueltigAb) {
|
||
// Der Anspruch iat steht in Sekunden, der Zeitstempel in Millisekunden.
|
||
const ausgestellt = sitzung.ausgestelltAm * 1000;
|
||
if (ausgestellt < benutzer.sitzungenUngueltigAb.getTime()) {
|
||
return { gueltig: false, grund: "widerrufen" };
|
||
}
|
||
}
|
||
return { gueltig: true };
|
||
}
|
||
|
||
export type Angemeldet = {
|
||
readonly benutzer: Benutzer;
|
||
readonly sitzung: Sitzung;
|
||
};
|
||
|
||
/**
|
||
* Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
|
||
* `sitzungErforderlich`.
|
||
*
|
||
* `cache` sorgt dafuer, dass mehrere Komponenten derselben Anfrage sich eine
|
||
* Abfrage teilen.
|
||
*/
|
||
export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
|
||
const laden = await cookies();
|
||
const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value);
|
||
if (!sitzung) return undefined;
|
||
|
||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||
const urteil = sitzungBewerten(sitzung, benutzer);
|
||
if (!urteil.gueltig || !benutzer) return undefined;
|
||
|
||
return { benutzer, sitzung };
|
||
});
|
||
|
||
/**
|
||
* Verlangt eine Anmeldung und – wenn angegeben – ein bestimmtes Recht.
|
||
*
|
||
* Ohne Anmeldung geht es zur Anmeldeseite, mit `ziel` auf die urspruenglich
|
||
* angeforderte Adresse. Genau daran haengt, dass ein gescannter QR-Code auch
|
||
* dann funktioniert, wenn noch niemand angemeldet war.
|
||
*/
|
||
export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise<Angemeldet> {
|
||
const vorhanden = await angemeldet();
|
||
if (!vorhanden) {
|
||
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
|
||
}
|
||
if (recht && !darf(vorhanden.benutzer.rolle, recht)) {
|
||
// Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
|
||
// der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
|
||
// Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt
|
||
// das Werfen richtig, siehe rechtErforderlich.
|
||
redirect("/anmelden?fehler=keinrecht");
|
||
}
|
||
return vorhanden;
|
||
}
|
||
|
||
/**
|
||
* Dasselbe fuer Server Actions und Route Handler: wirft statt weiterzuleiten,
|
||
* weil eine Weiterleitung mitten in einer Aktion nichts hilft.
|
||
*/
|
||
export async function rechtErforderlich(recht: Recht): Promise<Angemeldet> {
|
||
const vorhanden = await angemeldet();
|
||
if (!vorhanden) {
|
||
throw new RechteFehler("Nicht angemeldet.");
|
||
}
|
||
if (!darf(vorhanden.benutzer.rolle, recht)) {
|
||
throw new RechteFehler(
|
||
`Diese Aktion verlangt das Recht "${recht}", die Rolle ${vorhanden.benutzer.rolle} hat es nicht.`,
|
||
);
|
||
}
|
||
return vorhanden;
|
||
}
|