feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/** Lesemodelle der Benutzerverwaltung (#007). */
export type BenutzerZeile = {
readonly id: string;
readonly email: string;
readonly name: string;
readonly rolle: Rolle;
readonly aktiv: boolean;
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
readonly mitPasswort: boolean;
readonly ueberOidc: boolean;
readonly letzteAnmeldungAm: Date | null;
};
export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
const zeilen = await prisma.benutzer.findMany({
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
select: {
id: true,
email: true,
name: true,
rolle: true,
aktiv: true,
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
// ob ueberhaupt einer da ist.
passwortHash: true,
oidcSub: true,
letzteAnmeldungAm: true,
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
email: zeile.email,
name: zeile.name,
rolle: zeile.rolle,
aktiv: zeile.aktiv,
mitPasswort: zeile.passwortHash !== null,
ueberOidc: zeile.oidcSub !== null,
letzteAnmeldungAm: zeile.letzteAnmeldungAm,
}));
}