feat(auth): Anmeldung per OIDC mit Rollen aus Gruppen
Authorization Code mit PKCE, State und Nonce über openid-client 6.8.8; die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim. Lokale Konten werden nie über den Namen übernommen, verknüpfen geht nur angemeldet. Getestet gegen einen Mock-IdP inklusive gefälschtem State, falscher Nonce und fremder Audience. Closes #016 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { benutzerListe } from "@/server/services/benutzer";
|
||||
@@ -13,8 +16,10 @@ import { TokenVerwaltung } from "./token-verwaltung";
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */
|
||||
export default async function Einstellungen() {
|
||||
export default async function Einstellungen({ searchParams }: { searchParams: Promise<{ sso?: string }> }) {
|
||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||
const sso = oidcEinstellungen();
|
||||
const ssoFehler = (await searchParams).sso;
|
||||
const admin = darf(benutzer.rolle, "benutzerVerwalten");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -30,6 +35,35 @@ export default async function Einstellungen() {
|
||||
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
||||
/>
|
||||
{benutzer.passwortHash ? <PasswortAendern /> : null}
|
||||
{sso ? (
|
||||
<Karte titel="Anmeldung per SSO">
|
||||
<div className="flex flex-col gap-3">
|
||||
{ssoFehler === "schon_verknuepft" ? (
|
||||
<Status art="fehler">Dieses SSO-Konto gehört schon zu einem anderen Benutzer.</Status>
|
||||
) : null}
|
||||
{benutzer.oidcSubjekt ? (
|
||||
<Status art="erledigt">
|
||||
Mit {sso.anzeigename} verknüpft – die Rolle kommt bei jeder SSO-Anmeldung aus den Gruppen.
|
||||
</Status>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted text-sm">
|
||||
Dein Konto ist noch nicht mit {sso.anzeigename} verknüpft. Danach kannst du dich auch dort
|
||||
anmelden; die Rolle kommt dann aus den Gruppen beim Identity Provider.
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="/api/v1/auth/oidc/start?verknuepfen=1"
|
||||
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Mit {sso.anzeigename} verknüpfen
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
) : null}
|
||||
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Status } from "@/components/status";
|
||||
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
||||
import { Anmeldeformular } from "./anmeldeformular";
|
||||
|
||||
@@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string {
|
||||
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||
}
|
||||
|
||||
const SSO_MELDUNGEN: Record<string, string> = {
|
||||
abgelaufen: "Die SSO-Anmeldung hat zu lange gedauert. Bitte noch einmal.",
|
||||
idp: "Der Identity Provider hat die Anmeldung nicht bestätigt.",
|
||||
keine_gruppe: "Dein SSO-Konto ist für ProxDeploy nicht freigegeben – es fehlt die Gruppe.",
|
||||
gesperrt: "Dein Konto ist gesperrt.",
|
||||
name_vergeben: "Für dein SSO-Konto ließ sich kein Benutzername vergeben.",
|
||||
aus: "Die Anmeldung per SSO ist nicht eingerichtet.",
|
||||
};
|
||||
|
||||
export default async function Anmelden({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const ziel = sicheresZiel((await searchParams)["ziel"]);
|
||||
const parameter = await searchParams;
|
||||
const ziel = sicheresZiel(parameter["ziel"]);
|
||||
const sso = oidcEinstellungen();
|
||||
const ssoFehler =
|
||||
typeof parameter["sso"] === "string" ? (SSO_MELDUNGEN[parameter["sso"]] ?? SSO_MELDUNGEN["idp"]) : null;
|
||||
if (await aktuellerBenutzer()) redirect(ziel);
|
||||
|
||||
return (
|
||||
@@ -29,7 +44,27 @@ export default async function Anmelden({
|
||||
</h1>
|
||||
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
||||
</div>
|
||||
{ssoFehler ? (
|
||||
<Status art="fehler" className="mb-4 text-sm">
|
||||
{ssoFehler}
|
||||
</Status>
|
||||
) : null}
|
||||
<Anmeldeformular ziel={ziel} />
|
||||
{sso ? (
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="text-muted flex items-center gap-3 text-xs" aria-hidden="true">
|
||||
<span className="bg-linie h-px flex-1" />
|
||||
oder
|
||||
<span className="bg-linie h-px flex-1" />
|
||||
</div>
|
||||
<a
|
||||
href={`/api/v1/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
|
||||
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center justify-center rounded-lg border px-4 text-sm font-semibold"
|
||||
>
|
||||
Mit {sso.anzeigename} anmelden
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse, istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import {
|
||||
OIDC_COOKIE,
|
||||
oidcAbschliessen,
|
||||
oidcEinstellungen,
|
||||
OidcFehler,
|
||||
RUECKRUF_PFAD,
|
||||
} from "@/server/auth/oidc";
|
||||
|
||||
function cookieLesen(kopf: string | null, name: string): string | undefined {
|
||||
for (const teil of (kopf ?? "").split(";")) {
|
||||
const [schluessel, ...wert] = teil.trim().split("=");
|
||||
if (schluessel === name) return decodeURIComponent(wert.join("="));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich: Rückkehr vom Identity Provider. Erfolg → Sitzungscookie und Weiterleitung zum Ziel;
|
||||
* Fehler → Anmeldeseite mit Code (`?sso=keine_gruppe` …), nie eine Fehlerseite mit Interna.
|
||||
*/
|
||||
export async function GET(anfrage: Request): Promise<Response> {
|
||||
const herkunft = new URL(anfrage.url).origin;
|
||||
const e = oidcEinstellungen();
|
||||
const zurAnmeldung = (code: string) =>
|
||||
NextResponse.redirect(oeffentlicheAdresse(`/anmelden?sso=${code}`, herkunft), 302);
|
||||
if (!e) return zurAnmeldung("aus");
|
||||
|
||||
// Die öffentliche Adresse: genau die, die als redirect_uri beim IdP registriert ist.
|
||||
const aktuell = oeffentlicheAdresse(RUECKRUF_PFAD + new URL(anfrage.url).search, herkunft);
|
||||
let antwort: NextResponse;
|
||||
try {
|
||||
const { benutzer, ziel } = await oidcAbschliessen(
|
||||
e,
|
||||
aktuell,
|
||||
cookieLesen(anfrage.headers.get("cookie"), OIDC_COOKIE),
|
||||
aufruferAdresse(anfrage.headers),
|
||||
);
|
||||
antwort = NextResponse.redirect(oeffentlicheAdresse(ziel, herkunft), 302);
|
||||
antwort.cookies.set(
|
||||
SITZUNG_COOKIE,
|
||||
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "oidc" }),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: istHttps(anfrage.headers, anfrage.url),
|
||||
path: "/",
|
||||
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||
},
|
||||
);
|
||||
} catch (fehler) {
|
||||
if (!(fehler instanceof OidcFehler)) logger.error({ err: fehler }, "OIDC-Rückruf fehlgeschlagen");
|
||||
const code = fehler instanceof OidcFehler ? fehler.code : "idp";
|
||||
// Beim Verknüpfen ist man schon angemeldet – die Meldung gehört dann in die Einstellungen.
|
||||
antwort =
|
||||
code === "schon_verknuepft"
|
||||
? NextResponse.redirect(oeffentlicheAdresse(`/einstellungen?sso=${code}`, herkunft), 302)
|
||||
: zurAnmeldung(code);
|
||||
}
|
||||
antwort.cookies.set(OIDC_COOKIE, "", { path: "/api/v1/auth/oidc", maxAge: 0 });
|
||||
return antwort;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { api } from "@/server/api";
|
||||
import { anfrageAufrufer } from "@/server/auth/zugang";
|
||||
import { OIDC_COOKIE, oidcEinstellungen, oidcStarten, RUECKRUF_PFAD } from "@/server/auth/oidc";
|
||||
import { AnwendungsFehler, nichtAngemeldet } from "@/server/fehler";
|
||||
|
||||
/** Nur relative Ziele – sonst wäre `?ziel=` eine offene Weiterleitung. */
|
||||
function sicheresZiel(ziel: string | null): string {
|
||||
return ziel && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich: leitet zum Identity Provider. `?verknuepfen=1` (angemeldet) hängt das SSO-Konto an
|
||||
* das eigene lokale Konto statt anzumelden.
|
||||
*/
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const e = oidcEinstellungen();
|
||||
if (!e) throw new AnwendungsFehler(404, "oidc_aus", "Die Anmeldung per SSO ist nicht eingerichtet.");
|
||||
const abfrage = new URL(anfrage.url).searchParams;
|
||||
let verknuepfen: string | null = null;
|
||||
if (abfrage.get("verknuepfen") === "1") {
|
||||
const aufrufer = await anfrageAufrufer(anfrage);
|
||||
if (!aufrufer) throw nichtAngemeldet();
|
||||
verknuepfen = aufrufer.benutzer.id;
|
||||
}
|
||||
const herkunft = new URL(anfrage.url).origin;
|
||||
const { weiter, cookie } = await oidcStarten(
|
||||
e,
|
||||
oeffentlicheAdresse(RUECKRUF_PFAD, herkunft),
|
||||
sicheresZiel(abfrage.get("ziel")),
|
||||
verknuepfen,
|
||||
);
|
||||
const antwort = NextResponse.redirect(weiter, 302);
|
||||
antwort.cookies.set(OIDC_COOKIE, cookie, {
|
||||
httpOnly: true,
|
||||
// Lax: der Rückruf ist eine Top-Level-Navigation vom IdP zurück, da kommt das Cookie mit.
|
||||
sameSite: "lax",
|
||||
secure: istHttps(anfrage.headers, anfrage.url),
|
||||
path: "/api/v1/auth/oidc",
|
||||
maxAge: 600,
|
||||
});
|
||||
return antwort;
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import * as client from "openid-client";
|
||||
import type { Benutzer, Rolle } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Anmeldung per OpenID Connect (z. B. authentik): Authorization Code Flow mit PKCE, State und
|
||||
* Nonce. Die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim – der IdP ist maßgeblich.
|
||||
*
|
||||
* Konten: per `oidcSubjekt` (sub) zugeordnet. Ein lokales Konto wird **nie automatisch** über den
|
||||
* Benutzernamen übernommen – sonst könnte, wer beim IdP „admin“ heißt, den lokalen Admin
|
||||
* übernehmen. Verknüpfen geht nur angemeldet, aus den Einstellungen heraus.
|
||||
*/
|
||||
export type OidcEinstellungen = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
anzeigename: string;
|
||||
gruppenClaim: string;
|
||||
gruppeAdmin: string;
|
||||
gruppeOperator: string;
|
||||
};
|
||||
|
||||
export function oidcEinstellungen(
|
||||
umgebung: Record<string, string | undefined> = process.env,
|
||||
): OidcEinstellungen | null {
|
||||
if (umgebung["OIDC_AKTIV"] !== "true") return null;
|
||||
const issuer = umgebung["OIDC_ISSUER"]?.trim();
|
||||
const clientId = umgebung["OIDC_CLIENT_ID"]?.trim();
|
||||
if (!issuer || !clientId) {
|
||||
logger.warn("OIDC_AKTIV=true, aber OIDC_ISSUER oder OIDC_CLIENT_ID fehlt – OIDC bleibt aus.");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret: umgebung["OIDC_CLIENT_SECRET"] ?? "",
|
||||
anzeigename: umgebung["OIDC_ANZEIGENAME"]?.trim() || "SSO",
|
||||
gruppenClaim: umgebung["OIDC_GRUPPEN_CLAIM"]?.trim() || "groups",
|
||||
gruppeAdmin: umgebung["OIDC_GRUPPE_ADMIN"]?.trim() ?? "",
|
||||
gruppeOperator: umgebung["OIDC_GRUPPE_OPERATOR"]?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
let konfiguration: { schluessel: string; wert: Promise<client.Configuration> } | undefined;
|
||||
|
||||
/** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */
|
||||
async function konfigurationHolen(e: OidcEinstellungen): Promise<client.Configuration> {
|
||||
const schluessel = `${e.issuer}|${e.clientId}`;
|
||||
if (konfiguration?.schluessel !== schluessel) {
|
||||
const url = new URL(e.issuer);
|
||||
const wert = client.discovery(
|
||||
url,
|
||||
e.clientId,
|
||||
e.clientSecret || undefined,
|
||||
e.clientSecret ? undefined : client.None(),
|
||||
// http nur für Tests und Labore auf localhost – sonst verlangt openid-client zu Recht https.
|
||||
url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
|
||||
? { execute: [client.allowInsecureRequests] }
|
||||
: undefined,
|
||||
);
|
||||
konfiguration = { schluessel, wert };
|
||||
wert.catch(() => {
|
||||
if (konfiguration?.wert === wert) konfiguration = undefined;
|
||||
});
|
||||
}
|
||||
return await konfiguration.wert;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function oidcZwischenspeicherLeeren(): void {
|
||||
konfiguration = undefined;
|
||||
}
|
||||
|
||||
// ── Zustand zwischen Start und Rückruf: kurzlebiges, signiertes Cookie ───────────────────────────
|
||||
|
||||
export const OIDC_COOKIE = "pd_oidc";
|
||||
const OIDC_LAUFZEIT_S = 600;
|
||||
|
||||
type Ablauf = { verifier: string; state: string; nonce: string; ziel: string; verknuepfen: string | null };
|
||||
|
||||
function geheimnis(): Uint8Array {
|
||||
return new TextEncoder().encode(process.env["SITZUNG_GEHEIMNIS"] ?? "");
|
||||
}
|
||||
|
||||
export const RUECKRUF_PFAD = "/api/v1/auth/oidc/rueckruf";
|
||||
|
||||
/** Startet die Anmeldung: Weiterleitungsziel beim IdP und das Cookie für den Rückruf. */
|
||||
export async function oidcStarten(
|
||||
e: OidcEinstellungen,
|
||||
rueckruf: URL,
|
||||
ziel: string,
|
||||
verknuepfen: string | null,
|
||||
): Promise<{ weiter: URL; cookie: string }> {
|
||||
const config = await konfigurationHolen(e);
|
||||
const verifier = client.randomPKCECodeVerifier();
|
||||
const ablauf: Ablauf = {
|
||||
verifier,
|
||||
state: client.randomState(),
|
||||
nonce: client.randomNonce(),
|
||||
ziel,
|
||||
verknuepfen,
|
||||
};
|
||||
const weiter = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: rueckruf.href,
|
||||
scope: "openid profile email",
|
||||
code_challenge: await client.calculatePKCECodeChallenge(verifier),
|
||||
code_challenge_method: "S256",
|
||||
state: ablauf.state,
|
||||
nonce: ablauf.nonce,
|
||||
});
|
||||
const cookie = await new SignJWT(ablauf)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setAudience("proxdeploy-oidc")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${OIDC_LAUFZEIT_S}s`)
|
||||
.sign(geheimnis());
|
||||
return { weiter, cookie };
|
||||
}
|
||||
|
||||
export class OidcFehler extends Error {
|
||||
constructor(
|
||||
readonly code: "abgelaufen" | "idp" | "keine_gruppe" | "gesperrt" | "name_vergeben" | "schon_verknuepft",
|
||||
nachricht: string,
|
||||
) {
|
||||
super(nachricht);
|
||||
this.name = "OidcFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Ansprueche = Record<string, unknown> & { sub: string };
|
||||
|
||||
export function rolleAusGruppen(e: OidcEinstellungen, ansprueche: Ansprueche): Rolle | null {
|
||||
const roh = ansprueche[e.gruppenClaim];
|
||||
const gruppen = Array.isArray(roh) ? roh.map(String) : typeof roh === "string" ? [roh] : [];
|
||||
if (e.gruppeAdmin && gruppen.includes(e.gruppeAdmin)) return "ADMIN";
|
||||
if (e.gruppeOperator && gruppen.includes(e.gruppeOperator)) return "OPERATOR";
|
||||
// Ohne konfigurierte Gruppen genügt die Anmeldung beim IdP für „Operator“.
|
||||
if (!e.gruppeAdmin && !e.gruppeOperator) return "OPERATOR";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Aus preferred_username/E-Mail einen gültigen, freien Benutzernamen machen. */
|
||||
async function freierName(ansprueche: Ansprueche): Promise<string> {
|
||||
const quelle = String(
|
||||
ansprueche["preferred_username"] ?? String(ansprueche["email"] ?? "").split("@")[0] ?? "",
|
||||
);
|
||||
let basis = quelle
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9._-]/g, "-")
|
||||
.replace(/^[^a-z]+/, "")
|
||||
.slice(0, 28);
|
||||
if (basis.length < 2) basis = "sso-benutzer";
|
||||
for (let n = 0; n < 50; n++) {
|
||||
const name = n === 0 ? basis : `${basis}-${n + 1}`;
|
||||
if (!(await prisma.benutzer.findUnique({ where: { benutzername: name } }))) return name;
|
||||
}
|
||||
throw new OidcFehler("name_vergeben", "Kein freier Benutzername gefunden.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rückruf vom IdP: Code tauschen, ID-Token prüfen (Signatur, Issuer, Audience, Nonce), Konto
|
||||
* finden, anlegen oder verknüpfen, Rolle aus den Gruppen setzen.
|
||||
*/
|
||||
export async function oidcAbschliessen(
|
||||
e: OidcEinstellungen,
|
||||
aktuelleUrl: URL,
|
||||
cookie: string | undefined,
|
||||
ip: string | null,
|
||||
): Promise<{ benutzer: Benutzer; ziel: string }> {
|
||||
let ablauf: Ablauf;
|
||||
try {
|
||||
const { payload } = await jwtVerify(cookie ?? "", geheimnis(), {
|
||||
algorithms: ["HS256"],
|
||||
audience: "proxdeploy-oidc",
|
||||
});
|
||||
ablauf = payload as unknown as Ablauf;
|
||||
} catch {
|
||||
throw new OidcFehler(
|
||||
"abgelaufen",
|
||||
"Die Anmeldung hat zu lange gedauert oder kam nicht von hier. Bitte neu starten.",
|
||||
);
|
||||
}
|
||||
|
||||
const config = await konfigurationHolen(e);
|
||||
let ansprueche: Ansprueche;
|
||||
try {
|
||||
const antwort = await client.authorizationCodeGrant(config, aktuelleUrl, {
|
||||
pkceCodeVerifier: ablauf.verifier,
|
||||
expectedState: ablauf.state,
|
||||
expectedNonce: ablauf.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
ansprueche = antwort.claims() as Ansprueche;
|
||||
} catch (fehler) {
|
||||
logger.warn({ err: fehler }, "OIDC-Rückruf abgelehnt");
|
||||
throw new OidcFehler("idp", "Der Identity Provider hat die Anmeldung nicht bestätigt.");
|
||||
}
|
||||
|
||||
const rolle = rolleAusGruppen(e, ansprueche);
|
||||
const anzeigename = String(ansprueche["name"] ?? ansprueche["preferred_username"] ?? "SSO-Benutzer").slice(
|
||||
0,
|
||||
80,
|
||||
);
|
||||
const email = typeof ansprueche["email"] === "string" ? ansprueche["email"] : null;
|
||||
|
||||
// Verknüpfen: ein angemeldeter lokaler Benutzer hängt sein IdP-Konto an.
|
||||
if (ablauf.verknuepfen) {
|
||||
const vorhanden = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||
if (vorhanden && vorhanden.id !== ablauf.verknuepfen) {
|
||||
throw new OidcFehler(
|
||||
"schon_verknuepft",
|
||||
`Dieses SSO-Konto gehört schon zu „${vorhanden.benutzername}“.`,
|
||||
);
|
||||
}
|
||||
const benutzer = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.update({
|
||||
where: { id: ablauf.verknuepfen! },
|
||||
data: { oidcSubjekt: ansprueche.sub },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: b.id,
|
||||
quelle: "WEB",
|
||||
aktion: "SSO-Konto verknüpft",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return b;
|
||||
});
|
||||
return { benutzer, ziel: "/einstellungen" };
|
||||
}
|
||||
|
||||
if (!rolle) {
|
||||
await protokollieren({
|
||||
benutzerId: null,
|
||||
quelle: "WEB",
|
||||
aktion: "SSO-Anmeldung ohne passende Gruppe",
|
||||
ziel: `oidc:${ansprueche.sub}`,
|
||||
ip,
|
||||
});
|
||||
throw new OidcFehler(
|
||||
"keine_gruppe",
|
||||
"Dein SSO-Konto ist für ProxDeploy nicht freigegeben (Gruppe fehlt).",
|
||||
);
|
||||
}
|
||||
|
||||
const bekannt = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||
if (bekannt) {
|
||||
if (!bekannt.aktiv) throw new OidcFehler("gesperrt", "Dein Konto ist gesperrt.");
|
||||
const benutzer = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.update({
|
||||
where: { id: bekannt.id },
|
||||
data: { rolle, anzeigename, ...(email ? { email } : {}) },
|
||||
});
|
||||
if (bekannt.rolle !== rolle) {
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: b.id,
|
||||
quelle: "WEB",
|
||||
aktion: "Rolle aus SSO-Gruppen übernommen",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
details: { rolle: [bekannt.rolle, rolle] },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await protokollieren({ benutzerId: b.id, quelle: "WEB", aktion: "Angemeldet (SSO)", ip }, tx);
|
||||
return b;
|
||||
});
|
||||
return { benutzer, ziel: ablauf.ziel };
|
||||
}
|
||||
|
||||
const benutzername = await freierName(ansprueche);
|
||||
const benutzer = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.create({
|
||||
data: { benutzername, anzeigename, email, rolle, oidcSubjekt: ansprueche.sub },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: b.id,
|
||||
quelle: "WEB",
|
||||
aktion: "Benutzer per SSO angelegt",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
details: { rolle },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return b;
|
||||
});
|
||||
return { benutzer, ziel: ablauf.ziel };
|
||||
}
|
||||
Reference in New Issue
Block a user