From 7690718731de4d03dc407d1d9cf2d1b790c00453 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 9 Sep 2026 17:28:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Anmelden=20ueber=20Authentik=20?= =?UTF-8?q?=E2=80=93=20zusaetzlich=20zu=20Benutzername=20und=20Passwort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/main.py | 2 + backend/app/models.py | 72 ++- backend/app/oidc_config.py | 108 +++++ backend/app/routers/auth.py | 41 +- backend/app/routers/oidc.py | 373 +++++++++++++++ backend/app/services/backup_data.py | 6 + backend/app/services/oidc.py | 268 +++++++++++ .../versions/0033_oidc_anmeldung.py | 78 ++++ backend/requirements.txt | 5 + backend/tests/test_oidc.py | 433 ++++++++++++++++++ deploy/env.example | 45 ++ web/src/api.js | 9 + web/src/components/SsoVerknuepfung.jsx | 111 +++++ web/src/finance.css | 37 ++ web/src/locales/de.json | 13 +- web/src/locales/en.json | 13 +- web/src/pages/Account.jsx | 2 + web/src/pages/Login.jsx | 33 +- 18 files changed, 1633 insertions(+), 16 deletions(-) create mode 100644 backend/app/oidc_config.py create mode 100644 backend/app/routers/oidc.py create mode 100644 backend/app/services/oidc.py create mode 100644 backend/migrations/versions/0033_oidc_anmeldung.py create mode 100644 backend/tests/test_oidc.py create mode 100644 web/src/components/SsoVerknuepfung.jsx diff --git a/backend/app/main.py b/backend/app/main.py index b1deab3..75ed20e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -39,6 +39,7 @@ from .routers import ( policy, inbox, mail, + oidc, quick, uebertraege, rates, @@ -207,6 +208,7 @@ def version() -> dict: api.include_router(auth.router) +api.include_router(oidc.router) api.include_router(users.router) api.include_router(settings_router.router) api.include_router(policy.router) diff --git a/backend/app/models.py b/backend/app/models.py index a02682b..08cfd69 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -116,7 +116,11 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + # Leer bei reinen SSO-Nutzern: Wer sich nur ueber einen fremden + # Anmeldedienst anmeldet, hat hier nie ein Passwort gesetzt. NULL heisst + # ausdruecklich "kein Passwort-Login", nicht "leeres Passwort" – die + # Anmeldung prueft das, bevor sie ueberhaupt vergleicht. + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user) display_name: Mapped[str | None] = mapped_column(String(120), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) @@ -151,6 +155,72 @@ class User(Base): ) +class UserIdentity(Base): + """Ein Konto bei einem fremden Anmeldedienst, das zu diesem Nutzer gehoert. + + Eigene Tabelle und nicht zwei Spalten am Nutzer: Jemand kann sich ueber + mehrere Dienste anmelden, und die Kennung (``subject``) gilt immer nur + beim jeweiligen Anbieter. Zwei Nutzer duerfen dieselbe Kennung haben, + solange sie von verschiedenen Anbietern kommt. + + ``subject`` ist die ``sub``-Angabe aus dem ID-Token: die einzige, die der + Anbieter garantiert stabil haelt. Die E-Mail steht nur zur Anzeige dabei – + sie kann sich aendern, ohne dass es ein anderer Mensch wird. + """ + + __tablename__ = "user_identities" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + last_login_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + # Eine Kennung gehoert zu genau EINEM Nutzer. Ohne diese Regel koennte + # ein zweiter Nutzer dieselbe Kennung bekommen, und wer sich anmeldet, + # landete mal hier und mal dort. + UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"), + ) + + +class OidcState(Base): + """Ein begonnener Anmeldeversuch, bis der Nutzer zurueckkommt. + + Muss serverseitig liegen und nicht im Cookie: Der Nutzer verlaesst die + Seite und kommt ueber eine fremde Adresse zurueck – bei ``SameSite=lax`` + kaeme ein Cookie zwar mit, aber ``code_verifier`` und ``nonce`` gehoeren + ohnehin nicht in den Browser. + + Wird beim Einloesen GELOESCHT, nicht nur als benutzt markiert: Ein Code + darf genau einmal eingeloest werden. + """ + + __tablename__ = "oidc_states" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + state: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + nonce: Mapped[str] = mapped_column(String(64), nullable=False) + code_verifier: Mapped[str] = mapped_column(String(128), nullable=False) + redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False) + # Gesetzt, wenn ein bereits angemeldeter Nutzer sein Konto VERKNUEPFEN will + # statt sich anzumelden. Dann darf am Ende kein fremdes Konto entstehen. + link_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + # Wohin es in der Oberflaeche danach weitergeht. + weiter: Mapped[str | None] = mapped_column(String(255), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + class Session(Base): """Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash.""" diff --git a/backend/app/oidc_config.py b/backend/app/oidc_config.py new file mode 100644 index 0000000..779bef6 --- /dev/null +++ b/backend/app/oidc_config.py @@ -0,0 +1,108 @@ +"""Welche OpenID-Connect-Anbieter eingerichtet sind. + +Bewusst aus Umgebungsvariablen und nicht aus der Datenbank: Client-ID und +-Secret gehoeren zur Betriebsumgebung, nicht in ein Backup, und wer die +Anwendung umzieht, soll sie neu setzen muessen. + +Ein zweiter Anbieter ist reine Konfiguration – kein Code aendert sich: + + APP_OIDC_PROVIDERS=authentik,google + APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example/application/o/certain/.well-known/openid-configuration + APP_OIDC_AUTHENTIK_CLIENT_ID=... + APP_OIDC_AUTHENTIK_CLIENT_SECRET=... + APP_OIDC_AUTHENTIK_LABEL=Authentik + +Der Schluessel in ``APP_OIDC_PROVIDERS`` ist zugleich der Name in der Adresse +(``/api/auth/oidc/authentik/start``) und in der Datenbank – er darf sich +spaeter nicht aendern, sonst finden bestehende Verknuepfungen ihren Anbieter +nicht mehr wieder. +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from functools import lru_cache + +# Nur was sich gefahrlos in eine URL und eine Datenbankspalte schreiben laesst. +NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$") + +# Ohne diese drei Scopes fehlt entweder das ID-Token oder die E-Mail, ueber die +# ein bestehendes Konto gefunden wird. +STANDARD_SCOPES = "openid profile email" + + +@dataclass(frozen=True) +class Provider: + name: str # "authentik" – Schluessel in URL und Datenbank + label: str # "Mit Authentik anmelden" – was auf dem Knopf steht + discovery_url: str + client_id: str + client_secret: str + scopes: str = STANDARD_SCOPES + # Darf jemand, den es in Certain noch nicht gibt, ein Konto bekommen? + # + # Vorgabe NEIN, und das ist Absicht: Wer schon ein Konto hat und sich zum + # ersten Mal ueber den Anbieter anmeldet, bekaeme sonst ein zweites, leeres + # – seine Daten laegen weiter unter dem alten. Abgewiesen zu werden mit dem + # Hinweis "erst anmelden, dann verknuepfen" ist die hilfreichere Antwort. + # + # Auf true stellen, wenn sich neue Leute selbst anmelden koennen sollen. + # Dann entscheidet der Anbieter, wer in Certain darf. + auto_create: bool = False + # Wenn gesetzt: nur E-Mails dieser Domaenen duerfen neu anlegen. + allowed_domains: tuple[str, ...] = field(default_factory=tuple) + + +def _env(name: str, provider: str, standard: str = "") -> str: + return os.environ.get(f"APP_OIDC_{provider.upper()}_{name}", standard).strip() + + +def _bool(wert: str, standard: bool) -> bool: + if not wert: + return standard + return wert.lower() in ("1", "true", "yes", "ja", "on") + + +@lru_cache +def providers() -> dict[str, Provider]: + """Die eingerichteten Anbieter, nach Namen. + + Unvollstaendig konfigurierte werden ausgelassen und NICHT halb angeboten: + Ein Knopf, der ins Leere fuehrt, ist schlimmer als keiner. + """ + roh = os.environ.get("APP_OIDC_PROVIDERS", "") + gefunden: dict[str, Provider] = {} + for name in (n.strip().lower() for n in roh.split(",")): + if not name or not NAME_MUSTER.match(name): + continue + discovery = _env("DISCOVERY_URL", name) + client_id = _env("CLIENT_ID", name) + if not discovery or not client_id: + continue + domains = tuple( + d.strip().lower().lstrip("@") + for d in _env("ALLOWED_DOMAINS", name).split(",") + if d.strip() + ) + gefunden[name] = Provider( + name=name, + label=_env("LABEL", name) or name.capitalize(), + discovery_url=discovery, + client_id=client_id, + client_secret=_env("CLIENT_SECRET", name), + scopes=_env("SCOPES", name) or STANDARD_SCOPES, + auto_create=_bool(_env("AUTO_CREATE", name), False), + allowed_domains=domains, + ) + return gefunden + + +def get(name: str) -> Provider | None: + return providers().get((name or "").lower()) + + +def zuruecksetzen() -> None: + """Den Zwischenspeicher leeren – fuer Tests, die die Umgebung aendern.""" + providers.cache_clear() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ecd7390..d3aa84e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -55,7 +55,11 @@ def login( db: Session = Depends(get_db), ) -> LoginOut: user = db.query(User).filter(User.username == form.username).first() - if user is None or not verify_password(form.password, user.password_hash): + # Ein reines SSO-Konto hat kein Passwort. Der Vergleich wird gar nicht erst + # versucht – argon2 bekaeme sonst None und wuerfe einen Fehler, aus dem + # herauszulesen waere, dass es diesen Benutzernamen gibt. + ohne_passwort = user is not None and user.password_hash is None + if user is None or ohne_passwort or not verify_password(form.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzername oder Passwort falsch", @@ -88,6 +92,25 @@ def login( return LoginOut(status="ok", user=user_out(db, user)) +def _bestaetige_mit_passwort(user: User, passwort: str) -> None: + """Die eigene Identitaet mit dem Passwort bestaetigen. + + Ein reines SSO-Konto hat keines – dort ist diese Bestaetigung nicht + "falsch", sondern gar nicht moeglich. Ohne diese Unterscheidung bekaeme + argon2 None und der Nutzer eine Fehlermeldung, die nichts erklaert. + """ + if user.password_hash is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und " + "hat kein Passwort."), + ) + if not verify_password(passwort, user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch" + ) + + @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None: token = request.cookies.get(get_settings().cookie_name, "") @@ -111,6 +134,12 @@ def change_password( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> UserOut: + if current_user.password_hash is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und " + "hat kein Passwort."), + ) if not verify_password(payload.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -247,10 +276,7 @@ def new_recovery_codes( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv" ) - if not verify_password(payload.password, current_user.password_hash): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch" - ) + _bestaetige_mit_passwort(current_user, payload.password) return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user)) @@ -260,10 +286,7 @@ def disable_two_factor( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> UserOut: - if not verify_password(payload.password, current_user.password_hash): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch" - ) + _bestaetige_mit_passwort(current_user, payload.password) if policy_service.load(db).mfa_required: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py new file mode 100644 index 0000000..defeb80 --- /dev/null +++ b/backend/app/routers/oidc.py @@ -0,0 +1,373 @@ +"""Anmelden über einen fremden Dienst – und ein bestehendes Konto damit verknüpfen. + +Zwei Wege durch dieselbe Strecke: + +**Verknüpfen** (angemeldet). Der eigentliche Weg für alle, die schon ein Konto +haben: einmal wie gewohnt anmelden, in den Einstellungen „Mit … verknüpfen", +fertig. Danach geht beides – Passwort und SSO. + +**Anmelden** (nicht angemeldet). Ist die Kennung verknüpft, wird die Sitzung +angelegt. Ist sie es nicht, entsteht je nach Einstellung ein NEUES Konto – oder +die Anmeldung wird abgewiesen (Vorgabe). + +Was es bewusst nicht gibt: automatisches Zusammenführen über die E-Mail. Es +wäre der bequeme Weg und der gefährliche – wer beim Anbieter eine fremde +Adresse einträgt, übernähme damit ein fremdes Konto. + +Der zweite Faktor von Certain wird beim SSO-Login übersprungen: Der ist beim +Anbieter schon passiert, und ein zweites Mal fragen hiesse, ihm nicht zu +glauben, obwohl man ihn gerade zur Anmeldung ermächtigt hat. +""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from .. import oidc_config +from ..config import get_settings +from ..database import get_db +from ..deps import get_current_user +from ..models import OidcState, Role, User, UserIdentity +from ..services import oidc as oidc_service +from ..services import sessions + +router = APIRouter(prefix="/auth/oidc", tags=["auth"]) + +# Wie lange ein begonnener Anmeldeversuch gilt. Kurz: In dieser Zeit soll der +# Nutzer nur beim Anbieter auf "Anmelden" druecken. +STATE_TTL_MINUTEN = 15 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _redirect_uri(request: Request, provider: str) -> str: + """Die Adresse, an die der Anbieter zurueckschickt. + + Aus der Anfrage abgeleitet, damit Entwicklung (http://localhost:5173) und + Betrieb (https://…) ohne zweite Einstellung funktionieren. Sie muss beim + Anbieter EXAKT so eingetragen sein – auch beim Einloesen des Codes wird + sie noch einmal mitgeschickt und dort verglichen. + """ + return str(request.url_for("oidc_callback", provider=provider)) + + +def _fehlerseite(weiter: str, meldung: str) -> RedirectResponse: + """Zurueck in die Oberflaeche, mit dem Grund in der Adresse. + + Kein JSON: Hier landet ein BROWSER nach einer Weiterleitung, kein + Programm. Eine nackte Fehlerantwort waere eine weisse Seite. + """ + from urllib.parse import quote + + return RedirectResponse(f"{weiter}?oidc_error={quote(meldung[:300])}", status_code=303) + + +def _sicheres_ziel(weiter: str | None) -> str: + """Nur Ziele auf dieser Seite. + + Ohne diese Pruefung waere der Endpunkt eine offene Weiterleitung: Ein Link + auf ``…/start?weiter=https://woanders`` schickte den Nutzer nach der + Anmeldung auf eine fremde Seite, die wie ein Teil von Certain aussieht. + """ + if not weiter or not weiter.startswith("/") or weiter.startswith("//"): + return "/" + return weiter[:255] + + +@router.get("/providers") +def list_providers() -> dict: + """Welche Anmeldedienste eingerichtet sind. + + Ohne Anmeldung erreichbar: Die Login-Seite muss vor der Anmeldung wissen, + ob sie einen Knopf zeigen soll. Client-Secrets stehen hier nie. + """ + return { + "providers": [ + {"name": p.name, "label": p.label} + for p in oidc_config.providers().values() + ] + } + + +def _hole_provider(provider: str): + p = oidc_config.get(provider) + if p is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Kein Anmeldedienst '{provider}' eingerichtet.", + ) + return p + + +def _beginne(db: Session, request: Request, provider: str, weiter: str | None, + link_user: User | None) -> RedirectResponse: + p = _hole_provider(provider) + ziel = _redirect_uri(request, p.name) + try: + begonnen = oidc_service.start(p, ziel) + except oidc_service.OidcFehler as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e + + # Abgelaufene Versuche wegraeumen – sonst waechst die Tabelle still. + db.query(OidcState).filter(OidcState.expires_at < _now()).delete() + db.add(OidcState( + state=begonnen["state"], + provider=p.name, + nonce=begonnen["nonce"], + code_verifier=begonnen["code_verifier"], + redirect_uri=ziel, + link_user_id=link_user.id if link_user is not None else None, + weiter=_sicheres_ziel(weiter), + expires_at=_now() + timedelta(minutes=STATE_TTL_MINUTEN), + )) + db.commit() + return RedirectResponse(begonnen["url"], status_code=303) + + +@router.get("/{provider}/start") +def oidc_start(provider: str, request: Request, weiter: str | None = Query(default=None), + db: Session = Depends(get_db)): + """Anmeldung beginnen. Ohne Anmeldung erreichbar – das ist der Sinn.""" + return _beginne(db, request, provider, weiter, None) + + +@router.get("/{provider}/link") +def oidc_link(provider: str, request: Request, weiter: str | None = Query(default=None), + db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + """Das eigene, bestehende Konto mit dem Anbieter verknuepfen.""" + return _beginne(db, request, provider, weiter or "/konto", user) + + +def _freier_benutzername(db: Session, vorschlag: str) -> str: + """Ein Benutzername, den es noch nicht gibt.""" + basis = "".join(c for c in (vorschlag or "").strip().lower() + if c.isalnum() or c in "._-")[:48] or "nutzer" + name = basis + n = 2 + while db.query(User.id).filter(User.username == name).first() is not None: + name = f"{basis}{n}"[:64] + n += 1 + return name + + +def _darf_anlegen(p, anmeldung: oidc_service.Anmeldung) -> bool: + if not p.auto_create: + return False + if not p.allowed_domains: + return True + if not anmeldung.email: + return False + return anmeldung.email.rsplit("@", 1)[-1] in p.allowed_domains + + +def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> User: + """Zu wem gehoert dieser Anmeldende? + + Zwei Faelle, und dazwischen ausdruecklich NICHTS: + + 1. **Bekannte Kennung** – die Verknuepfung gibt es schon. Eindeutig. + 2. **Neu anlegen**, wenn der Anbieter das darf. + + Was hier bewusst FEHLT, ist der bequeme dritte Fall: ueber die E-Mail ein + vorhandenes Konto suchen und stillschweigend verknuepfen. Wer beim Anbieter + eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes Konto – + und die E-Mail ist die Angabe, die sich dort am leichtesten aendern laesst. + Ein bestehendes Konto wird ausdruecklich verknuepft, im angemeldeten + Zustand, ueber ``/link``. + """ + vorhanden = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == p.name, + UserIdentity.subject == anmeldung.subject) + .first() + ) + if vorhanden is not None: + vorhanden.email = anmeldung.email + vorhanden.last_login_at = _now() + nutzer = db.get(User, vorhanden.user_id) + if nutzer is None: + raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.") + return nutzer + + if not _darf_anlegen(p, anmeldung): + raise oidc_service.OidcFehler( + "Zu dieser Anmeldung gibt es kein Konto in Certain. Melde dich mit " + "Benutzername und Passwort an und verknuepfe dein Konto in den " + "Einstellungen." + ) + + nutzer = User( + username=_freier_benutzername( + db, anmeldung.preferred_username + or (anmeldung.email.split("@")[0] if anmeldung.email else "")), + # Kein Passwort: Dieses Konto meldet sich ueber den Anbieter an. + password_hash=None, + display_name=(anmeldung.name or "")[:120] or None, + role=Role.user, + is_active=True, + ) + db.add(nutzer) + db.flush() + db.add(UserIdentity(user_id=nutzer.id, provider=p.name, + subject=anmeldung.subject, email=anmeldung.email, + last_login_at=_now())) + return nutzer + + +@router.get("/{provider}/callback", name="oidc_callback") +def oidc_callback(provider: str, request: Request, response: Response, + code: str | None = Query(default=None), + state: str | None = Query(default=None), + error: str | None = Query(default=None), + error_description: str | None = Query(default=None), + db: Session = Depends(get_db)): + """Rueckkehr vom Anbieter.""" + eintrag = ( + db.query(OidcState).filter(OidcState.state == (state or "")).first() + if state else None + ) + # Das Ziel steht im gemerkten Versuch; ohne ihn bleibt nur die Startseite. + weiter = _sicheres_ziel(eintrag.weiter if eintrag is not None else None) + + if error: + return _fehlerseite(weiter, error_description or error) + if eintrag is None: + # Auch bei falschem state: Ohne Zuordnung waere jede Rueckkehr gueltig. + return _fehlerseite(weiter, "Diese Anmeldung ist abgelaufen oder gehoert " + "nicht hierher. Bitte noch einmal versuchen.") + + verbraucht = eintrag # ab hier gilt er als benutzt, egal wie es ausgeht + abgelaufen = eintrag.expires_at.replace(tzinfo=timezone.utc) < _now() \ + if eintrag.expires_at.tzinfo is None else eintrag.expires_at < _now() + nonce, verifier, ziel_uri = eintrag.nonce, eintrag.code_verifier, eintrag.redirect_uri + link_user_id = eintrag.link_user_id + db.delete(verbraucht) + db.commit() + + if abgelaufen: + return _fehlerseite(weiter, "Die Anmeldung hat zu lange gedauert. " + "Bitte noch einmal versuchen.") + if eintrag.provider != provider: + return _fehlerseite(weiter, "Die Antwort gehoert zu einem anderen Anmeldedienst.") + if not code: + return _fehlerseite(weiter, "Der Anmeldedienst hat keinen Code geschickt.") + + p = oidc_config.get(provider) + if p is None: + return _fehlerseite(weiter, f"Kein Anmeldedienst '{provider}' eingerichtet.") + + try: + anmeldung = oidc_service.schliesse_ab(p, code, ziel_uri, verifier, nonce) + except oidc_service.OidcFehler as e: + return _fehlerseite(weiter, str(e)) + + # --- Verknuepfen: der Nutzer ist schon angemeldet ----------------------- + if link_user_id is not None: + nutzer = db.get(User, link_user_id) + if nutzer is None: + return _fehlerseite(weiter, "Das Konto gibt es nicht mehr.") + schon = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == p.name, + UserIdentity.subject == anmeldung.subject) + .first() + ) + if schon is not None and schon.user_id != nutzer.id: + return _fehlerseite( + weiter, "Dieses Konto beim Anmeldedienst gehoert bereits zu einem " + "anderen Benutzer in Certain.") + if schon is None: + db.add(UserIdentity(user_id=nutzer.id, provider=p.name, + subject=anmeldung.subject, email=anmeldung.email, + last_login_at=_now())) + db.commit() + from urllib.parse import quote + return RedirectResponse(f"{weiter}?oidc_linked={quote(p.name)}", status_code=303) + + # --- Anmelden ----------------------------------------------------------- + try: + nutzer = _finde_oder_lege_an(db, p, anmeldung) + except oidc_service.OidcFehler as e: + db.rollback() + return _fehlerseite(weiter, str(e)) + + if not nutzer.is_active: + db.rollback() + return _fehlerseite(weiter, "Dieses Konto ist deaktiviert.") + db.commit() + + # Kein zweiter Faktor und kein Passwortwechsel: Beides gehoert zum + # Passwort-Login. Der Anbieter hat den Nutzer geprueft, und ein Passwort, + # das gewechselt werden muesste, gibt es hier womoeglich gar nicht. + raw = sessions.create_session(db, nutzer, request.headers.get("user-agent")) + antwort = RedirectResponse(weiter, status_code=303) + settings = get_settings() + antwort.set_cookie( + key=settings.cookie_name, + value=raw, + max_age=settings.session_ttl_days * 86400, + httponly=True, + secure=settings.cookie_secure, + samesite=settings.cookie_samesite, + path="/", + ) + return antwort + + +@router.get("/identities") +def my_identities(db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> dict: + """Womit dieses Konto verknuepft ist – fuer die Kontoseite.""" + bekannt = oidc_config.providers() + return { + "identities": [ + { + "id": i.id, + "provider": i.provider, + "label": bekannt[i.provider].label if i.provider in bekannt else i.provider, + "email": i.email, + "last_login_at": i.last_login_at.isoformat() if i.last_login_at else None, + } + for i in db.query(UserIdentity) + .filter(UserIdentity.user_id == user.id) + .order_by(UserIdentity.id) + ], + "hat_passwort": user.password_hash is not None, + } + + +@router.delete("/identities/{identity_id}", status_code=status.HTTP_204_NO_CONTENT) +def unlink(identity_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> None: + """Eine Verknuepfung loesen. + + Nicht die letzte, wenn es kein Passwort gibt: Sonst schliesst sich der + Nutzer aus seinem eigenen Konto aus, und niemand koennte ihn wieder + hereinlassen. + """ + eintrag = ( + db.query(UserIdentity) + .filter(UserIdentity.id == identity_id, UserIdentity.user_id == user.id) + .first() + ) + if eintrag is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Verknuepfung nicht gefunden") + uebrig = ( + db.query(UserIdentity) + .filter(UserIdentity.user_id == user.id, UserIdentity.id != identity_id) + .count() + ) + if uebrig == 0 and user.password_hash is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=("Das ist die einzige Anmeldung fuer dieses Konto. Setz erst " + "ein Passwort, sonst kommst du nicht mehr herein."), + ) + db.delete(eintrag) + db.commit() diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index bbc68ec..73095b9 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -51,6 +51,7 @@ from ..models import ( InboxItem, Subscription, SubscriptionLogo, + UserIdentity, LinkIgnored, PlannedIncomeLine, RecoveryCode, @@ -156,6 +157,11 @@ USER_TABLES: list[TableSpec] = [ # der Hash – der Klartext existiert nirgends. TableSpec("api_tokens", ApiToken, "owner", {"default_account_id": "accounts"}, label="Geräteschlüssel"), + # Aus demselben Grund wie die Geräteschlüssel: Ohne sie kommt nach einem + # Wiedereinspielen niemand mehr über den Anmeldedienst herein, und wer kein + # Passwort hat, gar nicht mehr. Enthält kein Geheimnis – nur die Kennung, + # unter der der Anbieter diesen Menschen führt. + TableSpec("user_identities", UserIdentity, "user", label="SSO-Verknüpfungen"), ] # Bewusst NICHT gesichert: diff --git a/backend/app/services/oidc.py b/backend/app/services/oidc.py new file mode 100644 index 0000000..1fd0961 --- /dev/null +++ b/backend/app/services/oidc.py @@ -0,0 +1,268 @@ +"""OpenID Connect: Anmeldung ueber einen fremden Anbieter (Authorization Code Flow). + +Geprueft wird mit Authlib, nicht von Hand. Ein ID-Token selbst zu zerlegen und +seine Signatur nachzurechnen ist die Art Code, die in neunzig Prozent der +Faelle funktioniert und in den uebrigen zehn jemanden hereinlaesst, der nicht +hereingehoert. + +Was hier passiert und warum: + +``state`` Verknuepft die Rueckkehr mit dem Aufbruch. Ohne ihn koennte jemand + eine fremde Rueckleitung in den Browser des Nutzers schieben. +``nonce`` Steht im ID-Token und wird gegen den gemerkten Wert geprueft. Ohne + ihn liesse sich ein anderswo erbeutetes, gueltiges Token einreichen. +``PKCE`` Bindet den Code an den, der ihn angefordert hat. Bei einem + vertraulichen Client (Secret) nicht zwingend, aber kostenlos – und + der Code bleibt derselbe, falls einmal ein oeffentlicher Client + dazukommt. + +Discovery und Schluessel werden kurz zwischengespeichert. Nicht laenger: Ein +Anbieter wechselt seine Signaturschluessel, und ein zu lange gehaltener +Schluesselsatz weist danach jeden ab. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import secrets +import time +from dataclasses import dataclass + +import httpx +from authlib.jose import JsonWebKey, JsonWebToken +from authlib.jose.errors import JoseError + +from ..oidc_config import Provider + +log = logging.getLogger(__name__) + +ZEITLIMIT = 15.0 +# So lange gelten Discovery-Dokument und Schluesselsatz als frisch. +CACHE_SEKUNDEN = 600 +# Wie viel Uhrzeit-Unterschied zwischen uns und dem Anbieter geduldet wird. +TOLERANZ_SEKUNDEN = 60 +# Nur asymmetrische Verfahren. "none" waere keine Signatur, und HS256 pruefte +# gegen das Client-Secret – beides gehoert hier nicht hin. +ERLAUBTE_ALGORITHMEN = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"] + + +class OidcFehler(RuntimeError): + """Fehler mit einem Grund, den man dem Nutzer zeigen kann.""" + + +@dataclass +class Anmeldung: + """Was nach erfolgreicher Pruefung ueber den Anmeldenden feststeht.""" + + subject: str + email: str | None + email_verified: bool + name: str | None + preferred_username: str | None + + +_cache: dict[str, tuple[float, dict]] = {} +_schluessel_cache: dict[str, tuple[float, object]] = {} + + +def _hole(url: str) -> dict: + try: + with httpx.Client(timeout=ZEITLIMIT, follow_redirects=True) as c: + antwort = c.get(url) + antwort.raise_for_status() + return antwort.json() + except httpx.HTTPError as e: + raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e + except ValueError as e: + raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e + + +def discovery(p: Provider) -> dict: + """Das Discovery-Dokument – gecacht.""" + eintrag = _cache.get(p.name) + if eintrag and eintrag[0] > time.time(): + return eintrag[1] + daten = _hole(p.discovery_url) + for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"): + if not daten.get(pflicht): + raise OidcFehler( + f"Das Discovery-Dokument von '{p.name}' hat kein '{pflicht}'. " + f"Zeigt die Adresse wirklich auf eine OpenID-Konfiguration?" + ) + _cache[p.name] = (time.time() + CACHE_SEKUNDEN, daten) + return daten + + +def _schluesselsatz(p: Provider, jwks_uri: str, erzwinge_neu: bool = False): + eintrag = _schluessel_cache.get(p.name) + if eintrag and eintrag[0] > time.time() and not erzwinge_neu: + return eintrag[1] + satz = JsonWebKey.import_key_set(_hole(jwks_uri)) + _schluessel_cache[p.name] = (time.time() + CACHE_SEKUNDEN, satz) + return satz + + +def cache_leeren() -> None: + _cache.clear() + _schluessel_cache.clear() + + +# --- Schritt 1: hinschicken -------------------------------------------------- + + +def _pkce() -> tuple[str, str]: + """(verifier, challenge) nach S256.""" + verifier = secrets.token_urlsafe(64) + rumpf = hashlib.sha256(verifier.encode("ascii")).digest() + return verifier, base64.urlsafe_b64encode(rumpf).rstrip(b"=").decode("ascii") + + +def start(p: Provider, redirect_uri: str) -> dict: + """Adresse, zu der der Nutzer geschickt wird – plus was wir uns merken muessen.""" + from urllib.parse import urlencode + + daten = discovery(p) + verifier, challenge = _pkce() + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + frage = { + "response_type": "code", + "client_id": p.client_id, + "redirect_uri": redirect_uri, + "scope": p.scopes, + "state": state, + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + return { + "url": f"{daten['authorization_endpoint']}?{urlencode(frage)}", + "state": state, + "nonce": nonce, + "code_verifier": verifier, + } + + +# --- Schritt 2: zurueckkommen ------------------------------------------------ + + +def _tausche_code(p: Provider, daten: dict, code: str, redirect_uri: str, + code_verifier: str) -> dict: + formular = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": p.client_id, + "code_verifier": code_verifier, + } + kopfzeilen = {"Accept": "application/json"} + # Ein oeffentlicher Client hat kein Secret und darf auch keines schicken. + auth = (p.client_id, p.client_secret) if p.client_secret else None + if auth is None: + formular.pop("client_id", None) + formular["client_id"] = p.client_id + try: + with httpx.Client(timeout=ZEITLIMIT) as c: + antwort = c.post(daten["token_endpoint"], data=formular, + headers=kopfzeilen, auth=auth) + except httpx.HTTPError as e: + raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e + if antwort.status_code >= 400: + grund = "" + try: + inhalt = antwort.json() + grund = inhalt.get("error_description") or inhalt.get("error") or "" + except ValueError: + pass + raise OidcFehler( + f"Der Anmeldedienst hat den Code abgelehnt{': ' + grund if grund else '.'}" + ) + try: + return antwort.json() + except ValueError as e: + raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e + + +def _pruefe_id_token(p: Provider, daten: dict, id_token: str, nonce: str) -> dict: + """Signatur, Aussteller, Empfaenger, Laufzeit und nonce. + + Bei einem unbekannten Schluessel wird der Satz EINMAL neu geholt: Anbieter + tauschen ihre Signaturschluessel aus, und ein gecachter Satz wiese danach + jeden ab, bis der Zwischenspeicher von selbst ablaeuft. + """ + anspruch = { + "iss": {"essential": True, "value": daten["issuer"]}, + "aud": {"essential": True, "value": p.client_id}, + "exp": {"essential": True}, + } + + def _lauf(erzwinge_neu: bool) -> dict: + satz = _schluesselsatz(p, daten["jwks_uri"], erzwinge_neu) + jwt = JsonWebToken(ERLAUBTE_ALGORITHMEN) + gelesen = jwt.decode(id_token, satz, claims_options=anspruch) + gelesen.validate(leeway=TOLERANZ_SEKUNDEN) + return gelesen + + try: + gelesen = _lauf(False) + except (JoseError, ValueError): + try: + gelesen = _lauf(True) + except JoseError as e: + raise OidcFehler(f"Das Token des Anmeldedienstes ist ungueltig: {e}") from e + except ValueError as e: + raise OidcFehler("Das Token des Anmeldedienstes ist unlesbar.") from e + + # Der nonce wird von Authlib nicht mitgeprueft – hier von Hand, sonst + # liesse sich ein anderswo erbeutetes, gueltiges Token einreichen. + if nonce and gelesen.get("nonce") != nonce: + raise OidcFehler("Die Antwort gehoert nicht zu dieser Anmeldung (nonce).") + if not gelesen.get("sub"): + raise OidcFehler("Das Token nennt keinen Nutzer (sub fehlt).") + return dict(gelesen) + + +def schliesse_ab(p: Provider, code: str, redirect_uri: str, code_verifier: str, + nonce: str) -> Anmeldung: + """Code einloesen, ID-Token pruefen, und sagen wer da hereinkommt.""" + daten = discovery(p) + tokens = _tausche_code(p, daten, code, redirect_uri, code_verifier) + id_token = tokens.get("id_token") + if not id_token: + raise OidcFehler( + "Der Anmeldedienst hat kein ID-Token geschickt. Fehlt der Scope " + "'openid' in der Konfiguration?" + ) + anspruch = _pruefe_id_token(p, daten, id_token, nonce) + + email = anspruch.get("email") + verifiziert = bool(anspruch.get("email_verified")) + # Manche Anbieter liefern die E-Mail erst am userinfo-Endpunkt. + if not email and daten.get("userinfo_endpoint") and tokens.get("access_token"): + try: + with httpx.Client(timeout=ZEITLIMIT) as c: + antwort = c.get( + daten["userinfo_endpoint"], + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + if antwort.status_code < 400: + weiteres = antwort.json() + # Nur vom selben Nutzer – sonst mischten wir zwei Identitaeten. + if weiteres.get("sub") == anspruch["sub"]: + email = weiteres.get("email") or email + verifiziert = bool(weiteres.get("email_verified")) or verifiziert + anspruch.setdefault("name", weiteres.get("name")) + anspruch.setdefault("preferred_username", + weiteres.get("preferred_username")) + except (httpx.HTTPError, ValueError): + log.info("userinfo von '%s' nicht abrufbar – ID-Token genuegt", p.name) + + return Anmeldung( + subject=str(anspruch["sub"]), + email=(email or "").strip().lower() or None, + email_verified=verifiziert, + name=anspruch.get("name"), + preferred_username=anspruch.get("preferred_username"), + ) diff --git a/backend/migrations/versions/0033_oidc_anmeldung.py b/backend/migrations/versions/0033_oidc_anmeldung.py new file mode 100644 index 0000000..7455d7d --- /dev/null +++ b/backend/migrations/versions/0033_oidc_anmeldung.py @@ -0,0 +1,78 @@ +"""Anmeldung ueber einen fremden Dienst (OpenID Connect). + +Drei Aenderungen: + +``user_identities`` welches Konto bei welchem Anbieter zu welchem Nutzer + gehoert. Eigene Tabelle, weil jemand mehrere haben kann. +``oidc_states`` begonnene Anmeldeversuche, bis der Nutzer zurueckkommt. +``users`` ein Passwort-Hash, der leer sein DARF – ein reiner + SSO-Nutzer hat nie eines gesetzt. + +Bewusst KEINE E-Mail-Spalte am Nutzer: Ueber die E-Mail automatisch +zusammenzufuehren waere der bequeme Weg und der gefaehrliche. Wer beim +Anbieter eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes +Konto. Verknuepft wird ausdruecklich, im angemeldeten Zustand. + +Revision ID: 0033 +Revises: 0032 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0033" +down_revision = "0032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "user_identities", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("subject", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + # Eine Kennung gehoert zu genau EINEM Nutzer – sonst landete derselbe + # Anmeldende mal hier und mal dort. + sa.UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"), + ) + op.create_index("ix_user_identities_user_id", "user_identities", ["user_id"]) + + op.create_table( + "oidc_states", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("state", sa.String(length=64), nullable=False, unique=True), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("nonce", sa.String(length=64), nullable=False), + sa.Column("code_verifier", sa.String(length=128), nullable=False), + sa.Column("redirect_uri", sa.String(length=500), nullable=False), + sa.Column("link_user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True), + sa.Column("weiter", sa.String(length=255), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_oidc_states_state", "oidc_states", ["state"]) + + # password_hash darf jetzt leer sein. Das geht auf SQLite nur ueber einen + # Tabellen-Umbau; migrations/env.py schaltet die Fremdschluessel dafuer ab, + # sonst risse das implizite DELETE beim DROP saemtliche Daten des Nutzers + # mit (siehe tests/test_migrations.py). + with op.batch_alter_table("users") as batch: + batch.alter_column("password_hash", existing_type=sa.String(length=255), + nullable=True) + + +def downgrade() -> None: + with op.batch_alter_table("users") as batch: + batch.alter_column("password_hash", existing_type=sa.String(length=255), + nullable=False) + op.drop_index("ix_oidc_states_state", table_name="oidc_states") + op.drop_table("oidc_states") + op.drop_index("ix_user_identities_user_id", table_name="user_identities") + op.drop_table("user_identities") diff --git a/backend/requirements.txt b/backend/requirements.txt index d74947b..5db064a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,6 +10,11 @@ argon2-cffi==23.1.0 pyotp==2.9.0 qrcode==8.0 python-multipart==0.0.20 +# Anmeldung ueber einen fremden Dienst (OpenID Connect): Discovery, JWKS und +# die Pruefung des ID-Tokens. Eine etablierte Bibliothek statt Eigenbau – ein +# selbst geschriebener Token-Pruefer funktioniert in neunzig Prozent der Faelle +# und laesst in den uebrigen zehn jemanden herein. +Authlib==1.6.5 httpx==0.28.1 python-dateutil==2.9.0.post0 # PDF/Bild-Import (Phase 5): Text aus PDFs (PyMuPDF) und OCR (Tesseract) für Scans/Bilder. diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py new file mode 100644 index 0000000..0801097 --- /dev/null +++ b/backend/tests/test_oidc.py @@ -0,0 +1,433 @@ +"""Anmeldung ueber einen fremden Dienst. + +Getestet wird gegen einen ECHTEN, aber gefaelschten Anbieter: eigenes +RSA-Schluesselpaar, eigenes Discovery-Dokument, eigenes JWKS. Ein Doppelgaenger, +der nur "ja" sagt, wuerde genau das nicht pruefen, worauf es hier ankommt – +naemlich dass ein falsch signiertes, abgelaufenes oder fuer jemand anderen +ausgestelltes Token abgewiesen wird. +""" + +import json +import secrets +import time + +import pytest +from authlib.jose import JsonWebKey, JsonWebToken + +from conftest import ADMIN_PASSWORD, login_ready, new_user + +ISSUER = "https://authentik.test" +CLIENT_ID = "certain-test" +DISCOVERY = f"{ISSUER}/application/o/certain/.well-known/openid-configuration" + + +@pytest.fixture() +def schluessel(): + """Ein RSA-Schluesselpaar, mit dem der gefaelschte Anbieter signiert.""" + return JsonWebKey.generate_key("RSA", 2048, is_private=True) + + +@pytest.fixture() +def anbieter(monkeypatch, schluessel): + """Richtet einen Anbieter ein und faengt seine Netzaufrufe ab. + + Gibt eine Funktion zurueck, mit der ein Test bestimmt, welche Angaben im + naechsten ID-Token stehen. + """ + from app import oidc_config + from app.services import oidc as oidc_service + + monkeypatch.setenv("APP_OIDC_PROVIDERS", "authentik") + monkeypatch.setenv("APP_OIDC_AUTHENTIK_DISCOVERY_URL", DISCOVERY) + monkeypatch.setenv("APP_OIDC_AUTHENTIK_CLIENT_ID", CLIENT_ID) + monkeypatch.setenv("APP_OIDC_AUTHENTIK_CLIENT_SECRET", "geheim") + monkeypatch.setenv("APP_OIDC_AUTHENTIK_LABEL", "Authentik") + oidc_config.zuruecksetzen() + oidc_service.cache_leeren() + + zustand = { + "claims": {}, + "signieren_mit": schluessel, + "token_status": 200, + } + + oeffentlich = json.loads(schluessel.as_json(is_private=False)) + oeffentlich["kid"] = oeffentlich.get("kid") or "test" + jwks = {"keys": [oeffentlich]} + dokument = { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/application/o/authorize/", + "token_endpoint": f"{ISSUER}/application/o/token/", + "jwks_uri": f"{ISSUER}/jwks", + "userinfo_endpoint": f"{ISSUER}/application/o/userinfo/", + } + + def _id_token(nonce: str) -> str: + jetzt = int(time.time()) + inhalt = { + "iss": ISSUER, "aud": CLIENT_ID, "sub": "sub-standard", + "iat": jetzt, "exp": jetzt + 300, "nonce": nonce, + } + inhalt.update(zustand["claims"]) + jwt = JsonWebToken(["RS256"]) + return jwt.encode({"alg": "RS256"}, inhalt, + zustand["signieren_mit"]).decode("ascii") + + class FakeAntwort: + def __init__(self, daten, status=200): + self._daten = daten + self.status_code = status + + def json(self): + return self._daten + + def raise_for_status(self): + if self.status_code >= 400: + import httpx + raise httpx.HTTPError(f"status {self.status_code}") + + class FakeClient: + def __init__(self, *a, **kw): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, **kw): + if url == DISCOVERY: + return FakeAntwort(dokument) + if url == dokument["jwks_uri"]: + return FakeAntwort(jwks) + if url == dokument["userinfo_endpoint"]: + return FakeAntwort({"sub": "sub-standard"}) + return FakeAntwort({}, 404) + + def post(self, url, data=None, headers=None, auth=None, **kw): + if url != dokument["token_endpoint"]: + return FakeAntwort({}, 404) + if zustand["token_status"] >= 400: + return FakeAntwort({"error": "invalid_grant"}, zustand["token_status"]) + zustand["gesehen"] = {"data": dict(data or {}), "auth": auth} + nonce = zustand.get("nonce", "") + return FakeAntwort({ + "access_token": "at", "token_type": "Bearer", + "id_token": _id_token(nonce), + }) + + monkeypatch.setattr("app.services.oidc.httpx.Client", FakeClient) + yield zustand + oidc_config.zuruecksetzen() + oidc_service.cache_leeren() + + +def _beginne(client, pfad="/api/auth/oidc/authentik/start", **params): + """Anmeldung/Verknuepfung starten und den state aus der Adresse holen.""" + from urllib.parse import parse_qs, urlparse + + r = client.get(pfad, params=params, follow_redirects=False) + assert r.status_code == 303, r.text + frage = parse_qs(urlparse(r.headers["location"]).query) + return frage["state"][0], frage["nonce"][0], frage + + +def _zurueck(client, zustand, state, nonce, code="guter-code"): + zustand["nonce"] = nonce + return client.get("/api/auth/oidc/authentik/callback", + params={"code": code, "state": state}, + follow_redirects=False) + + +# --- Einrichtung ------------------------------------------------------------- + + +def test_ohne_konfiguration_gibt_es_keinen_anbieter(client): + """Nichts eingerichtet heisst: Die Login-Seite zeigt keinen Knopf.""" + from app import oidc_config + + oidc_config.zuruecksetzen() + r = client.get("/api/auth/oidc/providers") + assert r.status_code == 200 + assert r.json()["providers"] == [] + + +def test_anbieter_wird_ohne_geheimnis_gemeldet(client, anbieter): + r = client.get("/api/auth/oidc/providers") + assert r.json()["providers"] == [{"name": "authentik", "label": "Authentik"}] + assert "geheim" not in r.text, "Client-Secret nach aussen gegeben" + + +def test_unbekannter_anbieter_ist_ein_vierhundertvier(client, anbieter): + r = client.get("/api/auth/oidc/gibtsnicht/start", follow_redirects=False) + assert r.status_code == 404 + + +# --- Der Weg hin ------------------------------------------------------------- + + +def test_start_schickt_mit_pkce_und_nonce_zum_anbieter(client, anbieter): + state, nonce, frage = _beginne(client) + assert frage["client_id"] == [CLIENT_ID] + assert frage["response_type"] == ["code"] + assert frage["code_challenge_method"] == ["S256"], "kein PKCE" + assert frage["code_challenge"][0] + assert state and nonce + assert "openid" in frage["scope"][0] + # Der verifier darf NICHT mitgeschickt werden – sonst waere PKCE sinnlos. + assert "code_verifier" not in frage + + +def test_zwei_anlaeufe_bekommen_verschiedene_kennungen(client, anbieter): + s1, n1, _ = _beginne(client) + s2, n2, _ = _beginne(client) + assert s1 != s2 and n1 != n2 + + +# --- Der Weg zurueck --------------------------------------------------------- + + +def test_erste_anmeldung_ohne_verknuepfung_wird_abgewiesen(client, anbieter): + """Vorgabe: kein Konto von selbst. Wer schon eines hat, soll verknuepfen – + sonst bekaeme er ein zweites, leeres.""" + state, nonce, _ = _beginne(client) + r = _zurueck(client, anbieter, state, nonce) + assert r.status_code == 303 + assert "oidc_error" in r.headers["location"] + assert client.get("/api/auth/me").status_code == 401, "trotzdem angemeldet" + + +def test_mit_auto_create_entsteht_ein_konto(client, anbieter, monkeypatch): + from app import oidc_config + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + anbieter["claims"] = {"sub": "neu-" + secrets.token_hex(4), + "email": "neu@example.com", "email_verified": True, + "preferred_username": "neuer" + secrets.token_hex(3)} + state, nonce, _ = _beginne(client) + r = _zurueck(client, anbieter, state, nonce) + assert r.status_code == 303, r.text + assert "oidc_error" not in r.headers["location"], r.headers["location"] + + ich = client.get("/api/auth/me") + assert ich.status_code == 200, "nicht angemeldet" + assert ich.json()["role"] == "user", "neues Konto darf kein Admin sein" + client.post("/api/auth/logout") + + +def test_falsche_signatur_kommt_nicht_herein(client, anbieter, monkeypatch): + """Der Kern: Ein Token, das jemand anders signiert hat, ist wertlos.""" + from app import oidc_config + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + anbieter["signieren_mit"] = JsonWebKey.generate_key("RSA", 2048, is_private=True) + anbieter["claims"] = {"sub": "faelscher"} + state, nonce, _ = _beginne(client) + r = _zurueck(client, anbieter, state, nonce) + assert "oidc_error" in r.headers["location"] + assert client.get("/api/auth/me").status_code == 401 + + +def test_fremder_empfaenger_kommt_nicht_herein(client, anbieter, monkeypatch): + """Ein Token, das fuer eine ANDERE Anwendung ausgestellt wurde.""" + from app import oidc_config + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + anbieter["claims"] = {"sub": "fremd", "aud": "eine-andere-app"} + state, nonce, _ = _beginne(client) + r = _zurueck(client, anbieter, state, nonce) + assert "oidc_error" in r.headers["location"] + assert client.get("/api/auth/me").status_code == 401 + + +def test_abgelaufenes_token_kommt_nicht_herein(client, anbieter, monkeypatch): + from app import oidc_config + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + jetzt = int(time.time()) + anbieter["claims"] = {"sub": "alt", "iat": jetzt - 4000, "exp": jetzt - 3600} + state, nonce, _ = _beginne(client) + r = _zurueck(client, anbieter, state, nonce) + assert "oidc_error" in r.headers["location"] + assert client.get("/api/auth/me").status_code == 401 + + +def test_falscher_nonce_kommt_nicht_herein(client, anbieter, monkeypatch): + """Sonst liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.""" + from app import oidc_config + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + anbieter["claims"] = {"sub": "nonce-test"} + state, nonce, _ = _beginne(client) + r = _zurueck(client, anbieter, state, "ein-ganz-anderer-nonce") + assert "oidc_error" in r.headers["location"] + assert client.get("/api/auth/me").status_code == 401 + + +def test_unbekannter_state_wird_abgewiesen(client, anbieter): + """Ohne diese Pruefung waere jede Rueckkehr gueltig.""" + r = client.get("/api/auth/oidc/authentik/callback", + params={"code": "x", "state": "nie-vergeben"}, + follow_redirects=False) + assert r.status_code == 303 + assert "oidc_error" in r.headers["location"] + + +def test_ein_state_gilt_nur_einmal(client, anbieter, monkeypatch): + """Ein Code darf genau einmal eingeloest werden.""" + from app import oidc_config + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + anbieter["claims"] = {"sub": "einmal-" + secrets.token_hex(4), + "preferred_username": "einmal" + secrets.token_hex(3)} + state, nonce, _ = _beginne(client) + assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"] + client.post("/api/auth/logout") + + zweiter = _zurueck(client, anbieter, state, nonce) + assert "oidc_error" in zweiter.headers["location"], "state ein zweites Mal gueltig" + assert client.get("/api/auth/me").status_code == 401 + + +def test_offene_weiterleitung_ist_nicht_moeglich(client, anbieter): + """`weiter` darf nur auf diese Seite zeigen – sonst waere der Endpunkt eine + Schleuder auf fremde Seiten, die wie Certain aussehen.""" + state, nonce, _ = _beginne(client, weiter="https://boese.example/phish") + r = _zurueck(client, anbieter, state, nonce) + assert not r.headers["location"].startswith("http"), r.headers["location"] + + +# --- Verknuepfen ------------------------------------------------------------- + + +def test_bestehendes_konto_verknuepfen_und_danach_damit_anmelden(admin, client, anbieter): + """Der eigentliche Weg: einmal normal anmelden, verknuepfen, fertig.""" + sub = "guido-" + secrets.token_hex(4) + anbieter["claims"] = {"sub": sub, "email": "guido@example.com", + "email_verified": True} + + state, nonce, _ = _beginne(admin, "/api/auth/oidc/authentik/link") + r = _zurueck(admin, anbieter, state, nonce) + assert "oidc_error" not in r.headers["location"], r.headers["location"] + assert "oidc_linked" in r.headers["location"] + + ident = admin.get("/api/auth/oidc/identities").json() + assert [i["provider"] for i in ident["identities"]] == ["authentik"] + assert ident["hat_passwort"] is True + + # Abmelden und jetzt ueber den Anbieter hereinkommen – dasselbe Konto. + admin.post("/api/auth/logout") + state, nonce, _ = _beginne(admin) + r = _zurueck(admin, anbieter, state, nonce) + assert "oidc_error" not in r.headers["location"], r.headers["location"] + ich = admin.get("/api/auth/me") + assert ich.status_code == 200 + assert ich.json()["username"] == "admin", "in einem fremden Konto gelandet" + + # Aufraeumen, damit die uebrigen Tests den Admin unveraendert vorfinden. + ident = admin.get("/api/auth/oidc/identities").json()["identities"][0] + assert admin.delete(f"/api/auth/oidc/identities/{ident['id']}").status_code == 204 + admin.post("/api/auth/login", + data={"username": "admin", "password": ADMIN_PASSWORD}) + + +def test_eine_kennung_gehoert_nur_einem_konto(admin, anbieter): + """Sonst landete derselbe Anmeldende mal hier und mal dort.""" + sub = "geteilt-" + secrets.token_hex(4) + anbieter["claims"] = {"sub": sub} + + state, nonce, _ = _beginne(admin, "/api/auth/oidc/authentik/link") + assert "oidc_linked" in _zurueck(admin, anbieter, state, nonce).headers["location"] + eigene = admin.get("/api/auth/oidc/identities").json()["identities"][0] + + username, passwort = new_user(admin) + anderer, _ = login_ready(username, passwort) + state, nonce, _ = _beginne(anderer, "/api/auth/oidc/authentik/link") + r = _zurueck(anderer, anbieter, state, nonce) + assert "oidc_error" in r.headers["location"], "dieselbe Kennung zweimal verknuepft" + assert anderer.get("/api/auth/oidc/identities").json()["identities"] == [] + + admin.delete(f"/api/auth/oidc/identities/{eigene['id']}") + + +def test_verknuepfen_verlangt_eine_anmeldung(client, anbieter): + r = client.get("/api/auth/oidc/authentik/link", follow_redirects=False) + assert r.status_code == 401 + + +def test_letzte_verknuepfung_bleibt_wenn_es_kein_passwort_gibt(admin, anbieter, monkeypatch): + """Sonst schliesst sich der Nutzer aus seinem eigenen Konto aus.""" + from app import oidc_config + from app.database import SessionLocal + from app.models import User, UserIdentity + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + sub = "ohnepw-" + secrets.token_hex(4) + anbieter["claims"] = {"sub": sub, "preferred_username": "ohnepw" + secrets.token_hex(3)} + state, nonce, _ = _beginne(admin) + admin.post("/api/auth/logout") + # Frischer Client, damit die Sitzung des neuen Kontos gilt. + from fastapi.testclient import TestClient + from app.main import app + neu = TestClient(app) + state, nonce, _ = _beginne(neu) + assert "oidc_error" not in _zurueck(neu, anbieter, state, nonce).headers["location"] + + daten = neu.get("/api/auth/oidc/identities").json() + assert daten["hat_passwort"] is False, "SSO-Konto hat wider Erwarten ein Passwort" + ident_id = daten["identities"][0]["id"] + r = neu.delete(f"/api/auth/oidc/identities/{ident_id}") + assert r.status_code == 400, "letzte Anmeldung liess sich entfernen" + assert "Passwort" in r.json()["detail"] + + with SessionLocal() as s: + eintrag = s.query(UserIdentity).filter(UserIdentity.subject == sub).first() + assert eintrag is not None, "Verknuepfung wurde doch geloescht" + s.delete(s.get(User, eintrag.user_id)) + s.commit() + + +# --- Zusammenspiel mit dem Passwort-Login ------------------------------------ + + +def test_passwort_login_bleibt_unveraendert(client): + """Der neue Weg kommt DAZU. Der alte muss weiter funktionieren.""" + r = client.post("/api/auth/login", + data={"username": "admin", "password": ADMIN_PASSWORD}) + assert r.status_code == 200, r.text + client.post("/api/auth/logout") + + +def test_konto_ohne_passwort_faellt_beim_passwort_login_nicht_durch(client, anbieter, + monkeypatch): + """argon2 bekaeme sonst None und wuerfe einen Fehler, aus dem sich ablesen + liesse, dass es diesen Benutzernamen gibt.""" + from app import oidc_config + from app.database import SessionLocal + from app.models import User, UserIdentity + + monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true") + oidc_config.zuruecksetzen() + name = "sso" + secrets.token_hex(4) + sub = "sso-" + secrets.token_hex(4) + anbieter["claims"] = {"sub": sub, "preferred_username": name} + state, nonce, _ = _beginne(client) + assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"] + client.post("/api/auth/logout") + + r = client.post("/api/auth/login", data={"username": name, "password": "irgendwas"}) + assert r.status_code == 401, r.text + assert "Passwort falsch" in r.json()["detail"] + + with SessionLocal() as s: + eintrag = s.query(UserIdentity).filter(UserIdentity.subject == sub).first() + s.delete(s.get(User, eintrag.user_id)) + s.commit() diff --git a/deploy/env.example b/deploy/env.example index 2c229a5..cd6cabb 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -36,3 +36,48 @@ APP_ECB_FETCH_ON_START=true # Wird vom Install-/Update-Skript gesetzt (kurzer Git-Commit, für /api/version). APP_GIT_SHA= + +# --- Anmeldung über einen fremden Dienst (OpenID Connect) -------------------- +# +# Optional. Bleibt APP_OIDC_PROVIDERS leer, ändert sich nichts: Es gibt weiter +# nur Benutzername und Passwort, und die Login-Seite zeigt keinen zusätzlichen +# Knopf. +# +# Der Name in der Liste ist zugleich der Name in der Adresse und in der +# Datenbank. Er darf sich später NICHT ändern, sonst finden bestehende +# Verknüpfungen ihren Anbieter nicht mehr wieder. +# +# Ein zweiter Anbieter ist reine Konfiguration – am Code ändert sich nichts: +# APP_OIDC_PROVIDERS=authentik,keycloak +# +#APP_OIDC_PROVIDERS=authentik + +# Die Discovery-Adresse deiner Anwendung in Authentik (Slug einsetzen). +#APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/certain/.well-known/openid-configuration + +# Aus Authentik: Provider-Typ "OAuth2/OpenID", Client-Typ "Confidential". +#APP_OIDC_AUTHENTIK_CLIENT_ID= +#APP_OIDC_AUTHENTIK_CLIENT_SECRET= + +# Was auf dem Knopf steht. +#APP_OIDC_AUTHENTIK_LABEL=Authentik + +# Als Redirect-URI trägst du in Authentik genau das hier ein +# (dieselbe Adresse, unter der du Certain aufrufst): +# https://certain.example.com/api/auth/oidc/authentik/callback + +# Darf jemand, den es in Certain noch nicht gibt, sich selbst ein Konto +# anlegen? Vorgabe: nein – und das ist Absicht. +# +# Wer schon ein Konto hat, verknüpft es EINMAL: normal anmelden, dann unter +# "Mein Konto" auf "Mit Authentik verknüpfen". Danach geht beides. Stünde das +# hier auf true, bekäme derselbe Mensch bei seiner ersten SSO-Anmeldung ein +# zweites, leeres Konto – seine Daten lägen weiter unter dem alten. +# +# Auf true stellen, wenn sich neue Leute selbst anmelden können sollen. Dann +# entscheidet Authentik, wer in Certain hereinkommt: Beschränke die Anwendung +# dort auf eine Gruppe. +#APP_OIDC_AUTHENTIK_AUTO_CREATE=false + +# Wenn AUTO_CREATE an ist: neue Konten nur für diese E-Mail-Domänen. +#APP_OIDC_AUTHENTIK_ALLOWED_DOMAINS=example.com,example.org diff --git a/web/src/api.js b/web/src/api.js index 202c721..13a3371 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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" }), diff --git a/web/src/components/SsoVerknuepfung.jsx b/web/src/components/SsoVerknuepfung.jsx new file mode 100644 index 0000000..7dcf2d4 --- /dev/null +++ b/web/src/components/SsoVerknuepfung.jsx @@ -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 ( +
+

+ {t("account.sso")} +

+

{t("account.ssoHint")}

+ +
+ {stand.identities.map((i) => ( +
+ + + {i.label} + {i.email && · {i.email}} + + +
+ ))} + + {dienste + .filter((d) => !verknuepft.has(d.name)) + .map((d) => ( + // Ein echter Link: Der Browser muss zum Anbieter wechseln. + + {t("account.ssoLink", { name: d.label })} + + ))} +
+ + {/* Wer kein Passwort hat, haengt an dieser einen Verknüpfung. Das gehört + gesagt, bevor er sie zu lösen versucht. */} + {!stand.hat_passwort && ( +

{t("account.ssoOnlyWay")}

+ )} +
+ ); +} diff --git a/web/src/finance.css b/web/src/finance.css index eea367c..1ca19e4 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -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; } diff --git a/web/src/locales/de.json b/web/src/locales/de.json index dc9544c..0970802 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d16372b..ea79f19 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Account.jsx b/web/src/pages/Account.jsx index 13b9795..76a6e31 100644 --- a/web/src/pages/Account.jsx +++ b/web/src/pages/Account.jsx @@ -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() { + diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx index 644a00b..8db958e 100644 --- a/web/src/pages/Login.jsx +++ b/web/src/pages/Login.jsx @@ -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")} + + {dienste.length > 0 && ( + <> +
{t("login.or")}
+ {dienste.map((d) => ( + // Ein echter Link, kein fetch: Der Browser MUSS die Seite + // wechseln – die Anmeldung passiert beim Anbieter. + + + {t("login.withProvider", { name: d.label })} + + ))} + + )} );