Files
qrcode-generator/src/app/api/auth/oidc/callback/route.ts
T
Florian Wilde 367d9c540b Bremsen, Sperren und CSP: die offenen Punkte schliessen
S3: Aus der Kontosperre wird eine Wartezeit. Die alte Sperre wies auch das
richtige Passwort ab — wer die Adresse eines Administrators kannte, konnte
ihn alle 15 Minuten neu aussperren. Jetzt bleiben vier Fehlversuche
folgenlos, danach verdoppelt sich die Wartezeit bis zu einer Minute. Der
Besitzer kommt spaetestens nach einer Minute hinein, ein Fremder bekommt
einen Versuch pro Minute. Ausgewertet wird sie vor der Passwortpruefung,
damit ein ohnehin abgewiesener Versuch keine 128 MiB kostet.

S4: Drei Stufen statt einer. Die Bremse je Absender liest
X-Forwarded-For nur noch mit TRUST_PROXY=true — ohne Proxy waehlt den Wert
jeder Aufrufer selbst, und eine so umgehbare Bremse ist eine Behauptung.
Dazu die Wartezeit je Konto, die an der Datenbank haengt, und eine
Obergrenze fuer die ganze Installation: 60 Passwortpruefungen und 120
Rueckmeldungen je Minute. Die letzte Stufe ist die einzige, die sich nicht
ueber Kopfzeilen umgehen laesst, und sie schuetzt den Speicher.

CSP: Eine vollstaendige Policy mit Nonce je Anfrage, strict-dynamic,
default-src self, object-src none, base-uri und form-action self. Skripte
ohne den Nonce dieser Anfrage laufen nicht mehr. Zwei Lockerungen bleiben
und stehen begruendet in der Doku: style-src erlaubt unsafe-inline, weil
ein Nonce keine style-Attribute deckt, und media-src erlaubt https, weil
der Typ Video ausdruecklich Direktlinks annimmt.

Protokoll: Sicherheitsereignisse als JSON auf die Standardausgabe —
Fehlversuche, OIDC-Verknuepfungen samt abgelehnten, Vergabe von
Administratorrechten. Ohne Passwoerter, Token und Adressen.

Refs #20
2026-08-24 01:43:00 +02:00

256 lines
8.5 KiB
TypeScript

import type { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { isAuthEnabled } from "@/lib/auth/actor";
import {
exchangeCode,
getOidcConfig,
isOidcEnabled,
OIDC_FLOW_COOKIE,
type OidcConfig,
type OidcIdentity,
} from "@/lib/auth/oidc";
import { createSession } from "@/lib/auth/session";
import { sicherheitsereignis } from "@/lib/auth/protokoll";
import { prisma } from "@/lib/db";
import { getBaseUrl } from "@/lib/env";
export const dynamic = "force-dynamic";
/** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */
function zurueckMitFehler(meldung: string): Response {
const ziel = new URL("/anmelden", getBaseUrl());
ziel.searchParams.set("fehler", meldung);
return Response.redirect(ziel.toString(), 302);
}
type FlowState = { state: string; nonce: string; codeVerifier: string };
/**
* Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts`
* über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in
* mehreren Bündeln und hätten dort verschiedene Identitäten.
*/
function istBenutzerfehler(error: unknown): error is Error {
return (
error instanceof Error &&
(error as Error & { userFacing?: unknown }).userFacing === true
);
}
function readFlow(raw: string | undefined): FlowState | null {
if (!raw) return null;
try {
const wert = JSON.parse(raw) as Partial<FlowState>;
if (
typeof wert.state === "string" &&
typeof wert.nonce === "string" &&
typeof wert.codeVerifier === "string"
) {
return wert as FlowState;
}
} catch {
// Unbrauchbarer Cookie — behandeln wie keiner.
}
return null;
}
/**
* Legt das Konto an oder findet es wieder.
*
* Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die
* E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes
* Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem
* lokal angelegten Administrator nicht plötzlich ein zweites Konto wird.
*/
async function findeOderLegeAn(
identity: OidcIdentity,
config: OidcConfig,
): Promise<{ id: string; isActive: boolean } | { fehler: string }> {
const nachSubject = await prisma.user.findUnique({
where: { oidcSubject: identity.subject },
});
const istAdmin = config.adminGroup
? identity.groups.includes(config.adminGroup)
: undefined;
if (nachSubject) {
const aktualisiert = await prisma.user.update({
where: { id: nachSubject.id },
data: {
email: identity.email,
name: identity.name ?? nachSubject.name,
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
// Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der
// Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist.
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
return { id: aktualisiert.id, isActive: aktualisiert.isActive };
}
const nachEmail = await prisma.user.findUnique({
where: { email: identity.email },
});
if (nachEmail) {
// Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
// übernehmen.
if (!identity.emailVerified) {
// Der Versuch gehört ins Protokoll: Wenn er gehäuft auftritt, stimmt
// entweder die Anbieterkonfiguration nicht — oder jemand probiert es.
sicherheitsereignis("oidc-verknuepfung-abgelehnt", {
konto: nachEmail.id,
grund: "email-nicht-bestaetigt",
});
return {
fehler:
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
};
}
const verknuepft = await prisma.user.update({
where: { id: nachEmail.id },
data: {
oidcSubject: identity.subject,
name: identity.name ?? nachEmail.name,
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
// Eine Verknüpfung verschiebt eine Vertrauensgrenze: Ab jetzt kommt man
// über den Anbieter in dieses Konto. Das soll nachvollziehbar sein.
sicherheitsereignis("oidc-konto-verknuepft", {
konto: verknuepft.id,
administrator: verknuepft.isInstanceAdmin,
});
return { id: verknuepft.id, isActive: verknuepft.isActive };
}
// Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter.
const standardgruppe = await prisma.group.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
//
// Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon
// jemanden?" und dem Anlegen könnte sonst eine zweite Anmeldung
// dazwischenkommen, und beide würden sich für die erste halten. Am Ende
// stünden zwei Administratoren da, von denen einer ungebeten ist.
const angelegt = await prisma.$transaction(
async (tx) => {
const istErster = (await tx.user.count()) === 0;
return tx.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
},
});
},
{ isolationLevel: "Serializable" },
);
if (angelegt.isInstanceAdmin) {
sicherheitsereignis("administrator-vergeben", {
konto: angelegt.id,
weg: istAdmin === undefined ? "erster-benutzer" : "gruppe-des-anbieters",
});
}
return { id: angelegt.id, isActive: angelegt.isActive };
}
export async function GET(request: NextRequest): Promise<Response> {
const store = await cookies();
try {
if (!isAuthEnabled() || !isOidcEnabled()) {
return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet.");
}
const params = request.nextUrl.searchParams;
// Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe.
const fehler = params.get("error");
if (fehler) {
const beschreibung = params.get("error_description");
console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung);
return zurueckMitFehler(
fehler === "access_denied"
? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?"
: "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
);
}
const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value);
if (!flow) {
return zurueckMitFehler(
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.",
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
return zurueckMitFehler("Die Antwort des Anbieters war unvollständig.");
}
if (state !== flow.state) {
// Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf.
return zurueckMitFehler(
"Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.",
);
}
const config = getOidcConfig();
const identity = await exchangeCode({
config,
code,
codeVerifier: flow.codeVerifier,
nonce: flow.nonce,
});
const ergebnis = await findeOderLegeAn(identity, config);
if ("fehler" in ergebnis) {
return zurueckMitFehler(ergebnis.fehler);
}
if (!ergebnis.isActive) {
return zurueckMitFehler(
"Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.",
);
}
await createSession(ergebnis.id);
const ziel = new URL("/", getBaseUrl());
return Response.redirect(ziel.toString(), 302);
} catch (error) {
console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error);
// Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind —
// alles andere könnte Interna verraten.
const meldung = istBenutzerfehler(error)
? error.message
: "Die Anmeldung ist fehlgeschlagen.";
return zurueckMitFehler(meldung);
} finally {
// In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut.
store.delete(OIDC_FLOW_COOKIE);
}
}