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
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sitzungBeenden } from "@/server/auth/anmeldung";
/**
* Abmelden. Nur per POST, damit ein fremder Link oder ein vorgeladenes Bild
* niemanden abmelden kann.
*/
export async function POST(anfrage: NextRequest): Promise<Response> {
await sitzungBeenden();
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?abgemeldet=1", anfrage.nextUrl.origin), {
status: 303,
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
/**
* Rueckruf des OIDC-Anbieters. Prueft den Fluss, uebernimmt den Benutzer und
* setzt die Sitzung.
*/
export async function GET(anfrage: NextRequest): Promise<Response> {
const flussToken = anfrage.cookies.get(OIDC_FLUSS_COOKIE)?.value;
try {
// Die Adresse fuer die Pruefung muss die oeffentliche sein – der Anbieter
// hat gegen die registrierte Rueckruf-Adresse signiert.
const aktuelleAdresse = oeffentlicheAdresse(
anfrage.nextUrl.pathname + anfrage.nextUrl.search,
anfrage.nextUrl.origin,
);
const anspruecke = await anmeldungAbschliessen(aktuelleAdresse, flussToken);
const benutzer = await benutzerAusOidcUebernehmen(anspruecke);
await sitzungSetzen(benutzer, "oidc");
const antwort = NextResponse.redirect(
oeffentlicheAdresse(sicheresZiel(anspruecke.ziel), anfrage.nextUrl.origin),
);
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
} catch (fehler) {
// Nur Name und Meldung: die cause-Kette von openid-client kann
// Antworten des Anbieters mitfuehren.
console.error(
`OIDC-Rueckruf fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
const antwort = NextResponse.redirect(
oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin),
);
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, ueberHttps } from "@/server/auth/anmeldung";
import { OIDC_FLUSS_COOKIE, anmeldungStarten, oidcAktiv } from "@/server/auth/oidc";
/** Beginnt die Anmeldung beim OIDC-Anbieter und leitet den Browser dorthin. */
export async function GET(anfrage: NextRequest): Promise<Response> {
if (!oidcAktiv()) {
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", anfrage.nextUrl.origin));
}
const ziel = sicheresZiel(anfrage.nextUrl.searchParams.get("ziel"));
try {
const { adresse, flussToken } = await anmeldungStarten(ziel);
const antwort = NextResponse.redirect(adresse);
antwort.cookies.set(OIDC_FLUSS_COOKIE, flussToken, {
httpOnly: true,
// Der Rueckruf kommt als Navigation vom Anbieter zurueck, also lax.
sameSite: "lax",
// Das Schema der eigenen Anwendung, nicht das des Anbieters: laeuft
// authentik auf https und Laufzettel im Intranet auf http, schickte der
// Browser ein Secure-Cookie nie zurueck und der Rueckruf scheiterte
// reproduzierbar mit "Anmeldevorgang abgelaufen".
secure: ueberHttps(),
path: "/api/auth/oidc",
maxAge: 600,
});
return antwort;
} catch (fehler) {
// Nur Name und Meldung: die cause-Kette von openid-client kann
// Antworten des Anbieters mitfuehren.
console.error(
`OIDC-Anmeldung konnte nicht begonnen werden:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-start", anfrage.nextUrl.origin));
}
}