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:
co-authored by
Claude Opus 5
parent
8d4954610b
commit
3d0ff54bf5
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 })}>
|
||||
|
||||
Reference in New Issue
Block a user