feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)

Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf
sshkv angepasst. Abweichungen mit Absicht:

- Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen
  Lesezugriff: schon die Hostliste verraet, wer wohin darf.
- Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges
  Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt
  einmal, fuenf Fehlversuche sperren 15 Minuten.
- Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf
  Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung
  von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis.
- Sitzungen laufen acht statt zwoelf Stunden.

Die Seiten folgen, sobald das Design-System (#002) steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:17:44 +02:00
co-authored by Claude Opus 5.5
parent 97e179d068
commit 5ef90310e1
20 changed files with 2320 additions and 0 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,
});
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
import { angemeldet } from "@/server/auth/sitzungspflicht";
import {
AnmeldeFehler,
oidcBenutzerUebernehmen,
oidcBestaetigungVermerken,
} from "@/server/services/anmeldung";
/**
* Rueckruf des OIDC-Anbieters: entweder Anmeldung (Sitzung setzen) oder erneute
* Bestaetigung des bereits angemeldeten Kontos (#004).
*/
export async function GET(anfrage: NextRequest): Promise<Response> {
const flussToken = anfrage.cookies.get(OIDC_FLUSS_COOKIE)?.value;
const ursprung = anfrage.nextUrl.origin;
try {
// Gepruefte Adresse muss die oeffentliche sein – dagegen hat der Anbieter signiert.
const aktuelleAdresse = oeffentlicheAdresse(anfrage.nextUrl.pathname + anfrage.nextUrl.search, ursprung);
const anspruecke = await anmeldungAbschliessen(aktuelleAdresse, flussToken);
if (anspruecke.erneut) {
const vorhanden = await angemeldet();
if (!vorhanden) throw new AnmeldeFehler("Für eine Bestätigung muss eine Sitzung bestehen.");
await oidcBestaetigungVermerken(vorhanden.benutzer.id, anspruecke);
} else {
const benutzer = await oidcBenutzerUebernehmen(anspruecke);
await sitzungSetzen(benutzer, "oidc");
}
const antwort = NextResponse.redirect(oeffentlicheAdresse(sicheresZiel(anspruecke.ziel), ursprung));
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
} catch (fehler) {
console.error(
"OIDC-Rueckruf fehlgeschlagen:",
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
// Nur ein Code geht in die Adresse, nie der Meldungstext.
const code = fehler instanceof AnmeldeFehler ? fehler.grund : "oidc-rueckruf";
const antwort = NextResponse.redirect(oeffentlicheAdresse(`/anmelden?fehler=${code}`, ursprung));
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
}
}
+40
View File
@@ -0,0 +1,40 @@
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. Mit `?erneut=1` wird daraus die erneute
* Bestaetigung vor einer kritischen Aktion (prompt=login, max_age=0) – siehe oidc.ts.
*/
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"));
const erneut = anfrage.nextUrl.searchParams.get("erneut") === "1";
try {
const { adresse, flussToken } = await anmeldungStarten(ziel, erneut);
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",
// Massgeblich ist das Schema von sshkv, nicht das des Anbieters (sonst kaeme ein
// Secure-Cookie ueber http nie zurueck).
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));
}
}