Anmeldung wahlweise ueber OpenID Connect (Authentik)

Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg:
OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration
aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher.

Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania
sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die
Rollenpruefung und die langlebigen API-Tokens unveraendert.

Backend
- Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein
  zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung.
- Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE)
  enden beide im selben Token-Schema.
- state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem
  Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit
  allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt.
  Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf
  bleibt mit der db-Fixture pruefbar.
- Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die
  BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein
  gleichlautender preferred_username uebernimmt nie ein bestehendes Konto.
- users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password
  faengt das ab, sonst antwortete der Login mit 500 statt 401.

Verknuepfen bestehender Konten
- Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen
  und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es
  braucht also weder eine hinterlegte Adresse noch Admin-Rechte.
- Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token
  nicht mitschicken kann.
- Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen.

iOS
- AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem
  eingebetteten WebView, und ohne Client-Secret.
- offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und
  bekaeme sonst einen ueberfluessigen Zustimmungsdialog.
- Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App
  still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung.

Deployment
- docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit
  fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht
  erfordern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 21:55:38 +02:00
co-authored by Claude Opus 5
parent 8d4954610b
commit 3d0ff54bf5
35 changed files with 2772 additions and 24 deletions
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
// Kennungen aus dem Rueckweg des Anmeldedienstes (`/konto#sso_error=…`).
const SSO_FEHLERTEXTE = {
abgebrochen: "Die Verknüpfung wurde abgebrochen.",
sitzung_abgelaufen: "Der Vorgang hat zu lange gedauert. Bitte noch einmal versuchen.",
state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.",
unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.",
anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.",
kennung_fremd:
"Diese Anmeldung gehört bereits zu einem anderen Vorrania-Konto. " +
"Melde dich dort ab oder verwende eine andere Kennung.",
schon_verknuepft: "Dieses Konto ist mit dem Anmeldedienst bereits verknüpft.",
konto_verschwunden: "Das Konto gibt es nicht mehr.",
};
function datum(wert) {
if (!wert) return "–";
return new Date(wert).toLocaleDateString("de-CH", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
/**
* „Mein Konto": zeigt, wie man sich an diesem Server anmelden kann, und
* verknüpft das Konto mit einem Anmeldedienst (Single Sign-on).
*
* Der Weg über diese Seite ist der einzige, der **ohne** hinterlegte
* E-Mail-Adresse funktioniert: Die Berechtigung stammt aus der laufenden
* Sitzung, nicht aus einem Claim des Anmeldedienstes.
*/
export default function Konto() {
const { user } = useAuth();
const confirm = useConfirm();
const toast = useToast();
const [anbieter, setAnbieter] = useState([]);
const [verknuepfungen, setVerknuepfungen] = useState([]);
const [fehler, setFehler] = useState(null);
const [laedt, setLaedt] = useState(true);
const [busy, setBusy] = useState(null);
async function laden() {
try {
const [liste, meine] = await Promise.all([
api.oidcProviders().catch(() => []),
api.oidcIdentities(),
]);
setAnbieter(liste || []);
setVerknuepfungen(meine || []);
} catch (err) {
setFehler(err.message);
} finally {
setLaedt(false);
}
}
useEffect(() => { laden(); }, []);
// Rueckmeldung aus dem Rueckweg einsammeln und die Adresszeile saeubern.
useEffect(() => {
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const verknuepft = fragment.get("sso_linked");
const kennung = fragment.get("sso_error");
if (!verknuepft && !kennung) return;
if (verknuepft) toast("Konto verknüpft.");
if (kennung) {
setFehler(SSO_FEHLERTEXTE[kennung] || "Die Verknüpfung ist fehlgeschlagen.");
}
window.history.replaceState(null, "", window.location.pathname);
}, [toast]);
async function verknuepfen(slug) {
setFehler(null);
setBusy(slug);
try {
// Erst die Adresse holen (mit Anmeldung im Header), dann dorthin
// navigieren – ein einfacher Link könnte das Token nicht mitschicken.
const { authorization_url } = await api.oidcLinkStart(slug);
window.location.assign(authorization_url);
} catch (err) {
setFehler(err.message);
setBusy(null);
}
}
async function loesen(slug, name) {
const ok = await confirm({
title: `Verknüpfung mit „${name}" lösen?`,
message: "Danach ist die Anmeldung über diesen Dienst nicht mehr möglich.",
confirmLabel: "Lösen",
danger: true,
});
if (!ok) return;
setFehler(null);
setBusy(slug);
try {
await api.oidcUnlink(slug);
toast("Verknüpfung gelöst.");
await laden();
} catch (err) {
setFehler(err.message);
} finally {
setBusy(null);
}
}
const verknuepftMit = (slug) => verknuepfungen.find((v) => v.provider === slug);
// Verknüpfungen zu Diensten, die es am Server nicht mehr gibt – lösen können
// muss man sie trotzdem, sonst hängen sie unsichtbar am Konto.
const verwaiste = verknuepfungen.filter(
(v) => !anbieter.some((a) => a.slug === v.provider)
);
return (
<div>
<div className="page-head">
<div>
<h1>Mein Konto</h1>
<div className="sub">Wie du dich an diesem Server anmeldest</div>
</div>
</div>
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
<div className="card">
<div className="card-head"><Icon name="users" /><h2>Konto</h2></div>
<dl className="kv">
<dt>Benutzername</dt><dd>{user?.username}</dd>
<dt>E-Mail</dt>
<dd>{user?.email || <span className="muted">nicht hinterlegt</span>}</dd>
<dt>Rolle</dt>
<dd>{user?.role === "admin" ? "Administrator" : "Benutzer"}</dd>
</dl>
</div>
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
<div className="card-head"><Icon name="settings" /><h2>Single Sign-on</h2></div>
{laedt ? (
<div className="muted">Lädt…</div>
) : anbieter.length === 0 && verwaiste.length === 0 ? (
<div className="muted small">
Auf diesem Server ist kein Anmeldedienst eingerichtet. Die Anmeldung
läuft über Benutzername und Passwort.
</div>
) : (
<>
<p className="muted small mt-0">
Verknüpfe dein Konto mit einem Anmeldedienst, um dich künftig
darüber anzumelden. Der Passwort-Login bleibt daneben bestehen.
</p>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Dienst</th><th>Verknüpft seit</th><th>Zuletzt genutzt</th><th></th></tr>
</thead>
<tbody>
{anbieter.map((a) => {
const v = verknuepftMit(a.slug);
return (
<tr key={a.slug}>
<td data-label="Dienst" className="strong">{a.display_name}</td>
<td data-label="Verknüpft seit">
{v ? datum(v.created_at) : <span className="muted">–</span>}
</td>
<td data-label="Zuletzt genutzt">
{v ? datum(v.last_login_at) : <span className="muted">–</span>}
</td>
<td className="num">
{v ? (
<button className="btn" disabled={busy === a.slug}
onClick={() => loesen(a.slug, a.display_name)}>
Lösen
</button>
) : (
<button className="btn primary" disabled={busy === a.slug}
onClick={() => verknuepfen(a.slug)}>
{busy === a.slug ? "Weiterleiten…" : "Verknüpfen"}
</button>
)}
</td>
</tr>
);
})}
{verwaiste.map((v) => (
<tr key={v.provider}>
<td data-label="Dienst" className="strong">
{v.display_name}
<div className="muted small">am Server nicht mehr eingerichtet</div>
</td>
<td data-label="Verknüpft seit">{datum(v.created_at)}</td>
<td data-label="Zuletzt genutzt">{datum(v.last_login_at)}</td>
<td className="num">
<button className="btn" disabled={busy === v.provider}
onClick={() => loesen(v.provider, v.display_name)}>
Lösen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
);
}
+56 -1
View File
@@ -1,9 +1,25 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import BrandMark from "../components/BrandMark";
// Der Server schickt nur eine Kennung zurueck (`/login#sso_error=…`), keinen
// Klartext – interne Einzelheiten bleiben im Serverprotokoll.
const SSO_FEHLERTEXTE = {
abgebrochen: "Die Anmeldung wurde abgebrochen.",
sitzung_abgelaufen: "Die Anmeldung hat zu lange gedauert. Bitte noch einmal versuchen.",
state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.",
unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.",
anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.",
kein_konto:
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss es " +
"anlegen und die E-Mail-Adresse hinterlegen.",
konto_bereits_verknuepft:
"Dieses Konto ist bereits mit einer anderen Kennung des Anmeldedienstes verknüpft.",
};
export default function Login() {
const { login, abgelaufen } = useAuth();
const navigate = useNavigate();
@@ -11,6 +27,27 @@ export default function Login() {
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [anbieter, setAnbieter] = useState([]);
// Fehlermeldung aus dem Single-Sign-on-Rueckweg einsammeln und die Adresszeile
// wieder saeubern.
useEffect(() => {
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const kennung = fragment.get("sso_error");
if (!kennung) return;
setError(SSO_FEHLERTEXTE[kennung] || "Die Anmeldung über den Anmeldedienst ist fehlgeschlagen.");
window.history.replaceState(null, "", window.location.pathname);
}, []);
// Ohne eingerichteten Anbieter bleibt die Liste leer und es erscheint kein
// zusaetzlicher Knopf. Ein Fehler hier darf den Passwort-Login nicht stoeren.
useEffect(() => {
let aktuell = true;
api.oidcProviders()
.then((liste) => { if (aktuell) setAnbieter(liste || []); })
.catch(() => {});
return () => { aktuell = false; };
}, []);
async function onSubmit(e) {
e.preventDefault();
@@ -52,6 +89,24 @@ export default function Login() {
<button className="btn primary" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
{busy ? "Anmelden…" : "Anmelden"}
</button>
{anbieter.length > 0 && (
<>
<div className="login-trenner">oder</div>
{anbieter.map((p) => (
// Bewusst ein echter Link und kein fetch: der Wechsel zum
// Anmeldedienst muss eine Seitennavigation des Browsers sein.
<a
key={p.slug}
className="btn"
style={{ width: "100%", justifyContent: "center" }}
href={`/api/auth/oidc/${encodeURIComponent(p.slug)}/login`}
>
Mit {p.display_name} anmelden
</a>
))}
</>
)}
</form>
</div>
);
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
/**
* Rueckweg vom Single-Sign-on: Der Server haengt das fertige Vorrania-Token als
* Fragment an (`/auth/callback#token=…`).
*
* Fragment statt Abfragezeichenkette, weil der Browser Fragmente NICHT an den
* Server schickt – das Token landet damit weder im nginx-Zugriffsprotokoll noch
* in einem Proxy-Log. Direkt nach dem Lesen wird es aus der Adresszeile geraeumt,
* damit es auch nicht im Verlauf stehen bleibt.
*/
export default function OidcCallback() {
const { tokenUebernehmen } = useAuth();
const navigate = useNavigate();
const [fehler, setFehler] = useState(null);
// React 18 ruft Effekte im Entwicklungsmodus doppelt auf – ohne diesen Riegel
// liefe die Uebernahme zweimal, das zweite Mal mit bereits geleertem Fragment.
const erledigt = useRef(false);
useEffect(() => {
if (erledigt.current) return;
erledigt.current = true;
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const token = fragment.get("token");
// Adresszeile sofort saeubern, egal wie es ausgeht.
window.history.replaceState(null, "", window.location.pathname);
if (!token) {
setFehler("Die Anmeldung kam ohne Token zurück.");
return;
}
tokenUebernehmen(token)
.then(() => navigate("/", { replace: true }))
.catch((err) => setFehler(err.message || "Anmeldung fehlgeschlagen."));
}, [tokenUebernehmen, navigate]);
if (fehler) {
return (
<div className="login-wrap">
<div className="card login-card">
<div className="alert error">
<Icon name="alert" size={16} />
{fehler}
</div>
<button className="btn primary" onClick={() => navigate("/login", { replace: true })}>
Zurück zur Anmeldung
</button>
</div>
</div>
);
}
return <div className="center muted" style={{ padding: 60 }}>Anmeldung wird abgeschlossen…</div>;
}
+34 -3
View File
@@ -10,7 +10,7 @@ export default function Users() {
const { user: me } = useAuth();
const toast = useToast();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ username: "", password: "", role: "user" });
const [form, setForm] = useState({ username: "", password: "", email: "", role: "user" });
const [error, setError] = useState(null);
async function load() {
@@ -28,7 +28,7 @@ export default function Users() {
setError(null);
try {
await api.createUser(form);
setForm({ username: "", password: "", role: "user" });
setForm({ username: "", password: "", email: "", role: "user" });
toast("Benutzer angelegt.");
load();
} catch (err) {
@@ -36,6 +36,21 @@ export default function Users() {
}
}
// Die Adresse ist der Schluessel fuer die erste SSO-Anmeldung: nur ueber sie
// findet ein Anmeldedienst das passende Bestandskonto.
async function changeEmail(u, email) {
if ((u.email || "") === email.trim()) return;
setError(null);
try {
await api.updateUser(u.id, { email: email.trim() });
toast("E-Mail-Adresse gespeichert.");
load();
} catch (err) {
setError(err.message);
load();
}
}
async function changeRole(u, role) {
try {
await api.updateUser(u.id, { role });
@@ -75,13 +90,21 @@ export default function Users() {
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td data-label="Name" className="strong">
{u.username}{u.id === me.id && <span className="muted"> (du)</span>}
</td>
<td data-label="E-Mail">
<input
type="email"
defaultValue={u.email || ""}
placeholder="für SSO"
onBlur={(e) => changeEmail(u, e.target.value)}
/>
</td>
<td data-label="Rolle">
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
disabled={u.id === me.id}>
@@ -114,6 +137,14 @@ export default function Users() {
<input type="password" value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} />
</label>
<label>
E-Mail (optional)
<input type="email" value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })} />
<span className="muted small">
Nur nötig, damit sich dieses Konto per Single Sign-on anmelden kann.
</span>
</label>
<label>
Rolle
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>