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.ssoHint")}
+ +{t("account.ssoOnlyWay")}
+ )} +