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
256 lines
8.5 KiB
TypeScript
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);
|
|
}
|
|
}
|