Anmeldung ueber Authentik und andere OIDC-Anbieter
Anmeldefluss mit PKCE (S256), State und Nonce. Das ID-Token wird gegen den JWKS des Anbieters geprueft — Signatur, Aussteller, Empfaenger, Ablauf und Nonce; alles Noetige kommt aus dem Discovery-Dokument, damit die Anbindung nicht auf Authentik festgelegt ist. Wiedererkannt wird jemand an der Kennung des Anbieters (sub), nicht an der E-Mail-Adresse — Adressen aendern sich. Ein bestehendes Konto mit derselben Adresse wird verknuepft statt verdoppelt, aber nur bei bestaetigter Adresse: sonst koennte sich beim Anbieter jemand eine fremde Adresse eintragen und damit ein hiesiges Konto uebernehmen. Uebernommen wird bewusst nur, wer jemand ist — plus die Instanz-Administration, wenn OIDC_ADMIN_GROUP gesetzt ist. Gruppen und Rollen bleiben Sache dieser Anwendung: die Gruppen im Verzeichnis bedeuten etwas anderes als die hier. Konten aus dem Verzeichnis haben kein Passwort mehr (passwordHash ist jetzt optional); Konto-Bereich und Passwort-Anmeldung sagen das statt an einer leeren Pruefung zu scheitern. Anleitung mit allen Feldern in Authentik: docs/authentik.md
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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 { 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) {
|
||||
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 }),
|
||||
},
|
||||
});
|
||||
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.
|
||||
const istErster = (await prisma.user.count()) === 0;
|
||||
|
||||
const angelegt = await prisma.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,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user