feat(auth): Anmeldung mit Passwort und OIDC, Schutz aller Seiten (#013)

Signierte Sitzungs-Cookies mit jose, OIDC ueber openid-client (authentik),
Schutz in proxy.ts, Anmeldeseite, Audit-Log-Ansicht, Sicherheits-Kopfzeilen und
Rate-Limit. Kein Auth.js, weil Version 5 Beta ist. 147 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 22:40:03 +02:00
co-authored by Claude Opus 5
parent 6cc39591b7
commit ee487775ec
26 changed files with 945 additions and 27 deletions
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { clientIp } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import type { AktionsErgebnis } from "./kunden";
/** Anmeldung mit E-Mail und Passwort (Rückfallweg, wenn OIDC nicht erreichbar ist). */
export async function anmelden(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
const email = formular.get("email")?.toString().trim() ?? "";
const passwort = formular.get("passwort")?.toString() ?? "";
const ziel = formular.get("ziel")?.toString() || "/";
if (!email || !passwort) {
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
}
try {
const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
});
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
// Nur eigene Pfade als Ziel zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
redirect(ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/");
}
export async function abmelden() {
const speicher = await cookies();
speicher.delete(SITZUNGS_COOKIE);
await protokolliere("abmeldung", "Benutzer", null, {});
redirect("/login");
}
+17 -2
View File
@@ -1,17 +1,32 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/**
* Schreibt einen Eintrag ins Audit-Log. Solange es keine Anmeldung gibt (#013),
* bleibt `benutzerId` leer und die Änderung gilt als vom System ausgelöst.
* Schreibt einen Eintrag ins Audit-Log. Läuft die Aktion in einer Anfrage, wird der angemeldete
* Benutzer vermerkt; im Worker oder in Skripten bleibt das Feld leer („System“).
*/
async function angemeldeterBenutzer(): Promise<string | null> {
try {
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
return sitzung?.benutzerId ?? null;
} catch {
// Außerhalb einer Anfrage gibt es keine Cookies – das ist kein Fehler.
return null;
}
}
export async function protokolliere(
aktion: string,
entitaet: string,
entitaetId: string | null,
aenderung?: Record<string, unknown>,
) {
const benutzerId = await angemeldeterBenutzer();
await prisma.auditEintrag.create({
data: {
benutzerId,
aktion,
entitaet,
entitaetId,
+77
View File
@@ -0,0 +1,77 @@
import { verify } from "@node-rs/argon2";
import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */
export const anmeldeRateLimit = new RateLimit(5, 5 / 900);
export class AnmeldeFehler extends Error {}
/** Prüft E-Mail und Passwort. Fehlermeldungen verraten bewusst nicht, welcher Teil falsch war. */
export async function mitPasswortAnmelden(
email: string,
passwort: string,
quelle: string,
): Promise<BenutzerModel> {
const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
}
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer?.passwortHash || !benutzer.aktiv) {
await protokolliere("anmeldung.fehlgeschlagen", "Benutzer", benutzer?.id ?? null, { email });
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
const stimmt = await verify(benutzer.passwortHash, passwort).catch(() => false);
if (!stimmt) {
await protokolliere("anmeldung.fehlgeschlagen", "Benutzer", benutzer.id, { email });
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passwort" });
return benutzer;
}
/**
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten.
*/
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim();
const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
(await prisma.benutzer.findUnique({ where: { email: normalisiert } }));
if (vorhanden && !vorhanden.aktiv) {
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
const benutzer = vorhanden
? await prisma.benutzer.update({
where: { id: vorhanden.id },
data: { oidcSubject: subject, name: name || vorhanden.name, letzterLoginAm: new Date() },
})
: await prisma.benutzer.create({
data: {
email: normalisiert,
name: name || normalisiert,
oidcSubject: subject,
letzterLoginAm: new Date(),
},
});
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, {
weg: "oidc",
neuAngelegt: vorhanden === null,
});
return benutzer;
}
+100
View File
@@ -0,0 +1,100 @@
import * as client from "openid-client";
/**
* Anmeldung über OIDC (getestet mit authentik). Die Einrichtung erfolgt über Umgebungsvariablen,
* damit Zugangsdaten nicht in der Datenbank liegen.
*/
export interface OidcEinstellungen {
ausstellerUrl: string;
clientId: string;
clientSecret: string;
/** Anzeigename auf dem Anmeldeknopf. */
bezeichnung: string;
}
export function oidcEinstellungen(): OidcEinstellungen | null {
const ausstellerUrl = process.env.OIDC_ISSUER;
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
if (!ausstellerUrl || !clientId || !clientSecret) return null;
return {
ausstellerUrl,
clientId,
clientSecret,
bezeichnung: process.env.OIDC_NAME ?? "authentik",
};
}
export function rueckkehrAdresse(): string {
const basis = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
return `${basis}/api/auth/oidc/callback`;
}
async function konfiguration(einstellungen: OidcEinstellungen) {
return client.discovery(
new URL(einstellungen.ausstellerUrl),
einstellungen.clientId,
einstellungen.clientSecret,
);
}
export interface AnmeldeStart {
url: string;
pruefwert: string;
zustand: string;
}
/** Baut die Adresse beim Anbieter samt PKCE-Prüfwert. */
export async function anmeldungStarten(einstellungen: OidcEinstellungen): Promise<AnmeldeStart> {
const konfig = await konfiguration(einstellungen);
const pruefwert = client.randomPKCECodeVerifier();
const herausforderung = await client.calculatePKCECodeChallenge(pruefwert);
const zustand = client.randomState();
const url = client.buildAuthorizationUrl(konfig, {
redirect_uri: rueckkehrAdresse(),
scope: "openid email profile",
code_challenge: herausforderung,
code_challenge_method: "S256",
state: zustand,
});
return { url: url.href, pruefwert, zustand };
}
export interface OidcBenutzer {
subject: string;
email: string;
name: string;
}
/** Löst den Rückkehr-Code beim Anbieter ein und liest die Benutzerangaben. */
export async function anmeldungAbschliessen(
einstellungen: OidcEinstellungen,
aktuelleUrl: URL,
pruefwert: string,
zustand: string,
): Promise<OidcBenutzer> {
const konfig = await konfiguration(einstellungen);
const tokens = await client.authorizationCodeGrant(konfig, aktuelleUrl, {
pkceCodeVerifier: pruefwert,
expectedState: zustand,
});
const angaben = tokens.claims();
if (!angaben?.sub) throw new Error("Der Anbieter hat keine Benutzerkennung geliefert.");
const email = typeof angaben.email === "string" ? angaben.email : "";
if (!email) throw new Error("Der Anbieter hat keine E-Mail-Adresse geliefert.");
const name =
(typeof angaben.name === "string" && angaben.name) ||
(typeof angaben.preferred_username === "string" && angaben.preferred_username) ||
email;
return { subject: angaben.sub, email, name };
}