feat: Anmelden ueber Authentik – zusaetzlich zu Benutzername und Passwort
Ein zweiter Weg herein, kein Ersatz. Wer weiter Passwort und Zwei-Faktor
benutzen will, merkt nichts: Ohne eingerichteten Anbieter sieht die
Login-Seite aus wie bisher.
DER WEG FUER BESTEHENDE KONTEN ist das Verknuepfen, nicht das Anmelden: einmal
wie gewohnt hereinkommen, unter "Mein Konto" auf "Mit Authentik verknuepfen",
fertig. Danach geht beides.
Was es ausdruecklich NICHT gibt, ist das automatische Zusammenfuehren ueber
die E-Mail. Es waere der bequeme Weg und der gefaehrliche: Wer beim Anbieter
eine fremde Adresse eintraegt, uebernaehme damit ein fremdes Konto – und die
E-Mail ist dort die Angabe, die sich am leichtesten aendern laesst. Deshalb
sagt der Nutzer selbst, wer er beim Anbieter ist, im angemeldeten Zustand.
Aus demselben Grund legt eine unbekannte Anmeldung standardmaessig KEIN Konto
an, sondern wird mit dem Hinweis abgewiesen, sich anzumelden und zu
verknuepfen. Sonst bekaeme genau der Mensch, der schon ein Konto hat, bei
seiner ersten SSO-Anmeldung ein zweites, leeres – und seine Daten laegen
weiter unter dem alten. Wer will, schaltet AUTO_CREATE ein.
Geprueft wird mit Authlib, nicht von Hand: Signatur gegen JWKS, Aussteller,
Empfaenger, Laufzeit und nonce. Dazu state gegen untergeschobene Rueckkehr und
PKCE, obwohl der Client vertraulich ist – es kostet nichts und der Code taugt
damit auch fuer einen oeffentlichen.
Ein zweiter Anbieter ist reine Konfiguration:
APP_OIDC_PROVIDERS=authentik,keycloak
Nebenbei zwei Stellen abgesichert, die es vorher nicht geben konnte: Ein Konto
ohne Passwort faellt beim Passwort-Login nicht mehr in argon2 (das haette
verraten, dass es diesen Benutzernamen gibt), und die letzte Verknuepfung
laesst sich nicht loesen, solange es kein Passwort gibt – sonst schliesst sich
der Nutzer aus seinem eigenen Konto aus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
01ae12ffea
commit
7690718731
@@ -152,6 +152,15 @@ export const api = {
|
||||
resetUserTwoFactor: (id) => request(`/users/${id}/2fa/reset`, { method: "POST" }),
|
||||
|
||||
// Einstellungen (je Benutzer)
|
||||
// Anmeldung ueber einen fremden Dienst. Start und Verknuepfen sind LINKS,
|
||||
// keine fetch-Aufrufe: Der Browser muss die Seite wirklich wechseln.
|
||||
oidcProviders: () => request("/auth/oidc/providers"),
|
||||
oidcStartUrl: (name) => `${API_BASE}/auth/oidc/${name}/start`,
|
||||
oidcLinkUrl: (name, weiter) =>
|
||||
`${API_BASE}/auth/oidc/${name}/link?weiter=${encodeURIComponent(weiter || "/konto")}`,
|
||||
oidcIdentities: () => request("/auth/oidc/identities"),
|
||||
oidcUnlink: (id) => request(`/auth/oidc/identities/${id}`, { method: "DELETE" }),
|
||||
|
||||
listSettings: () => request("/settings"),
|
||||
setSetting: (key, value) =>
|
||||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useConfirm } from "../confirm";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
/**
|
||||
* „Mein Konto" → Anmeldung über einen fremden Dienst.
|
||||
*
|
||||
* Das ist der Weg für alle, die schon ein Konto haben: einmal wie gewohnt
|
||||
* anmelden, hier verknüpfen, fertig. Danach geht beides.
|
||||
*
|
||||
* Bewusst KEINE Zusammenführung über die E-Mail im Hintergrund – wer beim
|
||||
* Anbieter eine fremde Adresse einträgt, übernähme damit ein fremdes Konto.
|
||||
* Deshalb sagt hier der Nutzer selbst, wer er dort ist.
|
||||
*
|
||||
* Zeigt sich gar nicht, wenn kein Dienst eingerichtet ist.
|
||||
*/
|
||||
export default function SsoVerknuepfung() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [dienste, setDienste] = useState([]);
|
||||
const [stand, setStand] = useState(null); // { identities, hat_passwort }
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
setStand(await api.oidcIdentities());
|
||||
} catch {
|
||||
setStand({ identities: [], hat_passwort: true });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.oidcProviders().then((r) => setDienste(r.providers)).catch(() => setDienste([]));
|
||||
laden();
|
||||
// Nach der Rückkehr vom Anbieter steht das Ergebnis in der Adresse – hier
|
||||
// landet ein Browser nach einer Weiterleitung, keine fetch-Antwort.
|
||||
const frage = new URLSearchParams(window.location.search);
|
||||
const fehler = frage.get("oidc_error");
|
||||
const verknuepft = frage.get("oidc_linked");
|
||||
if (fehler) toast(fehler, "error");
|
||||
else if (verknuepft) toast(t("account.ssoLinked", { name: verknuepft }));
|
||||
if (fehler || verknuepft) {
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function loesen(i) {
|
||||
const ok = await confirm({
|
||||
title: t("account.ssoUnlink"),
|
||||
message: t("account.ssoUnlinkConfirm", { name: i.label }),
|
||||
confirmLabel: t("account.ssoUnlink"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.oidcUnlink(i.id);
|
||||
toast(t("settings.saved"));
|
||||
laden();
|
||||
} catch (e) {
|
||||
toast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (dienste.length === 0 || stand === null) return null;
|
||||
|
||||
const verknuepft = new Set(stand.identities.map((i) => i.provider));
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
|
||||
<Icon name="key" size={18} /> {t("account.sso")}
|
||||
</h2>
|
||||
<p className="muted small">{t("account.ssoHint")}</p>
|
||||
|
||||
<div className="sso-liste">
|
||||
{stand.identities.map((i) => (
|
||||
<div className="sso-zeile" key={i.id}>
|
||||
<Icon name="check" size={15} className="sso-haken" />
|
||||
<span className="grow ellipsis">
|
||||
{i.label}
|
||||
{i.email && <span className="muted"> · {i.email}</span>}
|
||||
</span>
|
||||
<button className="btn sm ghost" onClick={() => loesen(i)}>
|
||||
{t("account.ssoUnlink")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dienste
|
||||
.filter((d) => !verknuepft.has(d.name))
|
||||
.map((d) => (
|
||||
// Ein echter Link: Der Browser muss zum Anbieter wechseln.
|
||||
<a key={d.name} className="btn sso-verknuepfen"
|
||||
href={api.oidcLinkUrl(d.name, "/konto")}>
|
||||
<Icon name="plus" size={15} /> {t("account.ssoLink", { name: d.label })}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Wer kein Passwort hat, haengt an dieser einen Verknüpfung. Das gehört
|
||||
gesagt, bevor er sie zu lösen versucht. */}
|
||||
{!stand.hat_passwort && (
|
||||
<p className="muted warn small">{t("account.ssoOnlyWay")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1924,3 +1924,40 @@ tr.row-dup {
|
||||
}
|
||||
.budget-vertragsliste li > :first-child { flex: 1; min-width: 0; }
|
||||
.budget-vertrag-betrag { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --- Anmeldung über einen fremden Dienst --------------------------------- */
|
||||
|
||||
/* Trennlinie mit „oder" auf der Login-Seite: Der SSO-Knopf ist eine
|
||||
Alternative zum Formular darüber, keine Fortsetzung davon. */
|
||||
.login-trenner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) 0 var(--sp-3);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.login-trenner::before,
|
||||
.login-trenner::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.btn.login-sso {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sso-liste { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sso-zeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sso-zeile:last-child { border-bottom: none; }
|
||||
.sso-haken { color: var(--ok); flex: none; }
|
||||
.btn.sso-verknuepfen { justify-content: center; text-decoration: none; }
|
||||
|
||||
+11
-2
@@ -32,7 +32,9 @@
|
||||
"password": "Passwort",
|
||||
"submit": "Anmelden",
|
||||
"error": "Benutzername oder Passwort falsch",
|
||||
"expired": "Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
|
||||
"expired": "Deine Sitzung ist abgelaufen. Bitte erneut anmelden.",
|
||||
"or": "oder",
|
||||
"withProvider": "Mit {{name}} anmelden"
|
||||
},
|
||||
"changepw": {
|
||||
"title": "Passwort ändern",
|
||||
@@ -70,7 +72,14 @@
|
||||
"title": "Mein Konto",
|
||||
"password": "Passwort ändern",
|
||||
"twoFactor": "Zwei-Faktor-Authentifizierung",
|
||||
"manage2fa": "2FA verwalten"
|
||||
"manage2fa": "2FA verwalten",
|
||||
"sso": "Anmeldung über einen anderen Dienst",
|
||||
"ssoHint": "Verknüpfe dein Konto einmal, danach kannst du dich wahlweise mit Passwort oder darüber anmelden. Deine Daten bleiben, wo sie sind.",
|
||||
"ssoLink": "Mit {{name}} verknüpfen",
|
||||
"ssoLinked": "Mit {{name}} verknüpft.",
|
||||
"ssoUnlink": "Lösen",
|
||||
"ssoUnlinkConfirm": "Die Verknüpfung mit {{name}} lösen? Du meldest dich danach wieder mit Benutzername und Passwort an.",
|
||||
"ssoOnlyWay": "Das ist derzeit die einzige Anmeldung für dieses Konto – ohne Passwort lässt sie sich nicht lösen."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
|
||||
+11
-2
@@ -32,7 +32,9 @@
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"error": "Wrong username or password",
|
||||
"expired": "Your session has expired. Please sign in again."
|
||||
"expired": "Your session has expired. Please sign in again.",
|
||||
"or": "or",
|
||||
"withProvider": "Sign in with {{name}}"
|
||||
},
|
||||
"changepw": {
|
||||
"title": "Change password",
|
||||
@@ -70,7 +72,14 @@
|
||||
"title": "My account",
|
||||
"password": "Change password",
|
||||
"twoFactor": "Two-factor authentication",
|
||||
"manage2fa": "Manage 2FA"
|
||||
"manage2fa": "Manage 2FA",
|
||||
"sso": "Sign in with another service",
|
||||
"ssoHint": "Link your account once, then sign in either with your password or through that service. Your data stays where it is.",
|
||||
"ssoLink": "Link with {{name}}",
|
||||
"ssoLinked": "Linked with {{name}}.",
|
||||
"ssoUnlink": "Unlink",
|
||||
"ssoUnlinkConfirm": "Unlink {{name}}? You will sign in with username and password again afterwards.",
|
||||
"ssoOnlyWay": "This is currently the only way into this account – without a password it cannot be unlinked."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -8,6 +8,7 @@ import Icon from "../components/Icon";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
import Kartenspalten from "../components/Kartenspalten";
|
||||
import PasswordRules from "../components/PasswordRules";
|
||||
import SsoVerknuepfung from "../components/SsoVerknuepfung";
|
||||
import Geraetemarken from "./settings/Geraetemarken";
|
||||
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
|
||||
|
||||
@@ -101,6 +102,7 @@ export default function Account() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SsoVerknuepfung />
|
||||
<Geraetemarken />
|
||||
</Kartenspalten>
|
||||
</div>
|
||||
|
||||
+31
-2
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../auth";
|
||||
import { ApiError } from "../api";
|
||||
import { ApiError, api } from "../api";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
import Icon from "../components/Icon";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
@@ -19,6 +19,20 @@ export default function Login() {
|
||||
const [zweiterSchritt, setZweiterSchritt] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [wiederherstellung, setWiederherstellung] = useState(false);
|
||||
// Eingerichtete Anmeldedienste. Leer = es gibt keine, dann steht hier auch
|
||||
// kein Knopf; die Anmeldung sieht aus wie bisher.
|
||||
const [dienste, setDienste] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.oidcProviders().then((r) => setDienste(r.providers)).catch(() => setDienste([]));
|
||||
// Der Anbieter schickt seinen Grund in der Adresse zurueck – hier landet
|
||||
// ein Browser nach einer Weiterleitung, nicht eine Antwort auf fetch().
|
||||
const grund = new URLSearchParams(window.location.search).get("oidc_error");
|
||||
if (grund) {
|
||||
setFehler(grund);
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -146,6 +160,21 @@ export default function Login() {
|
||||
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
|
||||
{busy ? t("common.loading") : t("login.submit")}
|
||||
</button>
|
||||
|
||||
{dienste.length > 0 && (
|
||||
<>
|
||||
<div className="login-trenner"><span>{t("login.or")}</span></div>
|
||||
{dienste.map((d) => (
|
||||
// Ein echter Link, kein fetch: Der Browser MUSS die Seite
|
||||
// wechseln – die Anmeldung passiert beim Anbieter.
|
||||
<a key={d.name} className="btn login-sso"
|
||||
href={api.oidcStartUrl(d.name)}>
|
||||
<Icon name="key" size={15} />
|
||||
{t("login.withProvider", { name: d.label })}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user