diff --git a/README.md b/README.md index 5db403e..91d7210 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**). - **Mehrbenutzer mit Rollen:** *Admin* verwaltet Stammdaten und Benutzer, *Nutzer* lagert nur ein/aus und sieht Bestände; alle Bewegungen werden protokolliert. +- **Single Sign-on (optional)** über OpenID Connect, z. B. gegen ein selbst + gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt + daneben bestehen – siehe [Single Sign-on](#single-sign-on-optional). ### In der iOS-App zusätzlich - **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat @@ -57,6 +60,7 @@ Details zur App: [ios/README.md](ios/README.md). | Teil | Technik | |---------|----------------------------------------| | Backend | Python · FastAPI · SQLAlchemy | +| Anmeldung | Passwort (JWT) und optional OpenID Connect | | DB | PostgreSQL (SQLite für Tests/lokal) | | Web-UI | React · Vite (via nginx ausgeliefert) | | iOS | Native SwiftUI-App (VisionKit-Scanner) | @@ -95,6 +99,76 @@ Nach dem Start: Die iOS-App wird mit derselben Adresse verbunden (`http://:8080`). +## Single Sign-on (optional) + +Neben dem Passwort-Login kann sich Vorrania an einen OpenID-Connect-Dienst +hängen (getestet mit Authentik). Ohne Konfiguration ändert sich nichts: Es gibt +keinen SSO-Knopf, und der Passwort-Login bleibt der einzige Weg. + +**1. In Authentik zwei Provider anlegen** – die Web-Oberfläche und die App +brauchen unterschiedliche Client-Typen: + +| Für | Typ | Redirect-URI | +|-----|-----|--------------| +| Web-UI | *Confidential* (mit Secret) | `https:///api/auth/oidc/authentik/callback` | +| iOS-App | *Public* (ohne Secret, PKCE) | `vorrania://oauth/callback` | + +Das `/api` in der Web-Adresse ist kein Versehen: Es entsteht im mitgelieferten +nginx und gehört deshalb in die bei Authentik hinterlegte Adresse. + +Damit die App ihre Anmeldung im Hintergrund erneuern kann, muss der +öffentliche Client `offline_access` ausliefern dürfen (in Authentik seit 2024.2 +ein eigenes Scope-Mapping) – sonst kommt kein Refresh-Token zurück. + +**2. `deploy/.env` ergänzen** (vollständige Vorlage in `deploy/.env.example`): + +```bash +PUBLIC_BASE_URL=https://vorrania.example.com +OIDC_PROVIDERS=authentik +OIDC_AUTHENTIK_DISPLAY_NAME=Authentik +OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration +OIDC_AUTHENTIK_CLIENT_ID=... +OIDC_AUTHENTIK_CLIENT_SECRET=... +OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios +``` + +Danach `./update.sh` (oder `docker compose --env-file .env up -d`). +Ob es geklappt hat, verrät `curl http://:8080/api/auth/oidc/providers`. + +**3. Bestehende Konten verknüpfen.** Dafür gibt es zwei Wege: + +*Selbst verknüpfen (empfohlen).* Angemeldet bleiben, in der Seitenleiste unten +auf den eigenen Namen klicken → **Mein Konto** → *Verknüpfen*. Das funktioniert +für jeden Benutzer, braucht keine hinterlegte E-Mail-Adresse und keine +Admin-Rechte: Die Berechtigung stammt aus der laufenden Sitzung. Dort lässt sich +eine Verknüpfung auch wieder lösen – außer es wäre die letzte +Anmeldemöglichkeit des Kontos. + +*Über die E-Mail-Adresse.* Trage sie unter *Benutzer* ein – dieselbe wie in +Authentik. Beim ersten SSO-Login wird das Konto dann automatisch gefunden. +Verknüpft wird nur bei **bestätigter** Adresse. + +Ohne eines von beidem entsteht beim ersten Anmelden ein **zweites** Konto mit +der Rolle *Benutzer*, statt dass das bestehende übernommen wird. + +Weitere Anbieter sind reine Konfiguration: Kürzel in `OIDC_PROVIDERS` ergänzen +und einen zweiten Block mit dessen Präfix anlegen (`OIDC__…`) – am Code +ändert sich dafür nichts. + +### Was dabei bewusst so gebaut ist +- Der Anmeldedienst ist **nur für die Anmeldung** zuständig. Danach stellt + Vorrania sein eigenes Token aus, wie beim Passwort-Login – Rollen, API-Tokens + und alle Endpunkte bleiben unverändert. +- **Rollen kommen nie aus dem Anbieter.** Neu angelegte SSO-Konten sind immer + *Benutzer*; Admin vergibt ein Admin von Hand. +- **Automatisch** verknüpft wird nur bei bestätigter E-Mail-Adresse. Sonst + könnte ein Konto im Anmeldedienst mit frei gewählter Adresse ein fremdes + Vorrania-Konto übernehmen. Beim Verknüpfen über *Mein Konto* entfällt das, + weil dort die eigene Sitzung bürgt – eine bereits anderweitig vergebene + Kennung wird aber auch dort abgelehnt. +- Die App meldet sich ausschließlich im **System-Browser** an (AppAuth mit + PKCE), nie in einem eingebetteten WebView, und sendet nie ein Client-Secret. + ## Aktualisieren ```bash cd vorrania/deploy diff --git a/backend/app/config.py b/backend/app/config.py index f7de33c..80de033 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,6 +18,12 @@ class Settings(BaseSettings): admin_username: str = "admin" admin_password: str = "changeme" + # Oeffentliche Adresse der Instanz, z.B. "https://vorrania.example.com". + # Wird fuer die OIDC-Rueckleit-Adresse gebraucht und laesst sich NICHT aus dem + # Request ableiten: das /api-Praefix entsteht erst in nginx, und es gibt weder + # root_path noch ProxyHeadersMiddleware. + public_base_url: str = "" + # Open Food Facts (Lebensmittel) off_base_url: str = "https://world.openfoodfacts.org" # Open Products Facts (allgemeine Produkte / Gegenstände) – gleiche API-Struktur. diff --git a/backend/app/main.py b/backend/app/main.py index 50a46de..67731c5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from .routers import ( items, locations, maintenance, + oidc, package_types, products, settings as settings_router, @@ -212,6 +213,14 @@ def _ensure_schema() -> None: # Vorschaubild (Briefmarkengröße) neben dem Original. Bestehende Bilder # bekommen es beim ersten Abruf; deshalb nullbar und ohne Umzug. "ALTER TABLE product_images ADD COLUMN IF NOT EXISTS thumb BYTEA", + # Single Sign-on: E-Mail am Benutzer (nur fuer die Erstverknuepfung) und + # ein Passwort-Hash, der bei reinen SSO-Konten leer bleiben darf. + # DROP NOT NULL ist auf einer bereits nullbaren Spalte ein No-Op, der + # Aufruf also wiederholbar. + "ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)", + "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) " + "WHERE email IS NOT NULL", ] with engine.begin() as conn: # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), @@ -277,6 +286,7 @@ def health() -> dict: app.include_router(auth.router) +app.include_router(oidc.router) app.include_router(users.router) app.include_router(products.router) app.include_router(stock.router) diff --git a/backend/app/models.py b/backend/app/models.py index 1e6d5d3..f26755f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -118,10 +118,86 @@ 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 Konten, die es nur ueber Single Sign-on gibt. Ein solches Konto ist + # per Passwort schlicht nicht anmeldbar (siehe routers/auth.py). + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Nur fuer die Erstverknuepfung mit einem SSO-Konto. Bewusst nicht Pflicht: + # bestehende Installationen haben keine Adressen hinterlegt. + email: Mapped[str | None] = mapped_column(String(255), nullable=True) role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + identities: Mapped[list[UserIdentity]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + __table_args__ = ( + # Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal + # uebernimmt – zwei Konten mit derselben Adresse waeren also mehrdeutig. + # Gross-/Kleinschreibung darf dabei keinen Unterschied machen, und leere + # Adressen (der Normalfall bei Passwort-Konten) bleiben aussen vor. + Index( + "uq_users_email", + text("lower(email)"), + unique=True, + postgresql_where=text("email IS NOT NULL"), + sqlite_where=text("email IS NOT NULL"), + ), + ) + + +class OidcLoginState(Base): + """Merkzettel zwischen „zum Anmeldedienst geschickt" und „zurueckgekommen". + + Bewusst eine Tabelle und kein Sitzungs-Cookie. Dieses Backend kennt sonst + gar keine Cookies, und Starlettes CORS-Schicht wechselt bei + ``allow_origins=["*"]`` **zusammen mit** ``allow_credentials=True`` auf die + gespiegelte Herkunft, sobald ein Request ueberhaupt ein Cookie mitbringt + (starlette/middleware/cors.py). Ohne Cookie entsteht diese Wechselwirkung + erst gar nicht – und mit der ``db``-Fixture laesst sich der Ablauf pruefen, + was bei einer Middleware ohne TestClient nicht ginge. + """ + + __tablename__ = "oidc_login_states" + + state: Mapped[str] = mapped_column(String(64), primary_key=True) + provider: Mapped[str] = mapped_column(String(64), nullable=False) + nonce: Mapped[str] = mapped_column(String(64), nullable=False) + code_verifier: Mapped[str] = mapped_column(String(128), nullable=False) + # Gesetzt, wenn der Vorgang eine VERKNUEPFUNG ist statt einer Anmeldung: Der + # Rueckweg haengt die Identitaet dann an genau dieses (bereits angemeldete) + # Konto, statt jemanden anzumelden. Die Berechtigung dafuer stammt aus der + # laufenden Vorrania-Sitzung, nicht aus der E-Mail-Adresse. + link_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class UserIdentity(Base): + """Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter. + + Eigene Tabelle statt zweier Spalten am Benutzer, damit ein Konto spaeter + Identitaeten bei MEHREREN Anbietern haben kann (Authentik heute, etwas + anderes morgen) – genau die Erweiterbarkeit, um die es beim OIDC-Umbau geht. + """ + + __tablename__ = "user_identities" + __table_args__ = (UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + # Das Kuerzel aus OIDC_PROVIDERS, z.B. "authentik". + provider: Mapped[str] = mapped_column(String(64), nullable=False) + # Die sub-Angabe des Anbieters – stabil, auch wenn sich die E-Mail aendert. + subject: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="identities") + # Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht # nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit diff --git a/backend/app/oidc_config.py b/backend/app/oidc_config.py new file mode 100644 index 0000000..12b6cc6 --- /dev/null +++ b/backend/app/oidc_config.py @@ -0,0 +1,192 @@ +"""OIDC-Anmeldeanbieter aus Umgebungsvariablen lesen. + +Ein Anbieter wird NICHT im Code hinterlegt, sondern über die Umgebung beschrieben. +``OIDC_PROVIDERS`` zaehlt die Kuerzel auf, jedes Kuerzel bekommt einen eigenen +Block:: + + OIDC_PROVIDERS=authentik + OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration + OIDC_AUTHENTIK_CLIENT_ID=... + OIDC_AUTHENTIK_CLIENT_SECRET=... + +Ein zweiter Anbieter ist damit reine Konfiguration (``OIDC_PROVIDERS=authentik,keycloak`` +plus zweiter Block) – ohne Codeaenderung. ``pydantic-settings`` kann solche +dynamischen Praefixe nicht, deshalb dieser eigene kleine Leser statt eines Feldes +in ``Settings``. + +Fehlt die Konfiguration ganz, ist die Liste leer und die Anwendung laeuft wie +bisher rein mit Passwort-Login weiter. Das ist wichtig, weil ``deploy/update.sh`` +bestehende ``.env``-Dateien nicht nachtraegt. +""" + +from __future__ import annotations + +import logging +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass +from functools import lru_cache + +logger = logging.getLogger(__name__) + +# Der Browser-Weg braucht KEIN Refresh-Token: das Backend loest den Code ein, +# stellt sein eigenes Vorrania-Token aus und wirft den Rest weg. offline_access +# hier anzufordern wuerde nur einen zusaetzlichen Zustimmungsdialog ausloesen. +STANDARD_SCOPES = "openid profile email" + +# Die App dagegen haelt die Anmeldung selbst und erneuert sie im Hintergrund. +# offline_access ist dafuer Pflicht und kein Beiwerk: Authentik liefert seit +# 2024.2 ohne diesen Scope KEIN Refresh-Token. +STANDARD_NATIVE_SCOPES = "openid profile email offline_access" + +# Custom-URL-Scheme der iOS-App. Der Scheme "vorrania" ist in der Info.plist +# bereits registriert (dort fuer vorrania://checkin und vorrania://i/). +STANDARD_NATIVE_REDIRECT = "vorrania://oauth/callback" + +_ERLAUBTES_KUERZEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$") + + +@dataclass(frozen=True) +class OidcProvider: + """Ein konfigurierter Anmeldeanbieter.""" + + slug: str + display_name: str + discovery_url: str + client_id: str + # Nur der vertrauliche Web-Client hat ein Secret. Die App ist ein oeffentlicher + # Client und sendet nie eins – deshalb steht es hier auch nur fuer den Web-Weg. + client_secret: str = "" + scopes: str = STANDARD_SCOPES + native_scopes: str = STANDARD_NATIVE_SCOPES + # Client-IDs der nativen (oeffentlichen) Clients. Ein ID-Token aus der App + # traegt eine ANDERE aud als der Web-Client, deshalb eine eigene Liste. + native_client_ids: tuple[str, ...] = () + native_redirect_uri: str = STANDARD_NATIVE_REDIRECT + redirect_uri: str = "" + auto_create: bool = True + # Verknuepfung mit einem bestehenden Konto nur bei bestaetigter E-Mail. Wer das + # abschaltet, erlaubt einem IdP-Konto mit frei gewaehlter Adresse die Uebernahme + # eines fremden Vorrania-Kontos. + require_verified_email: bool = True + + @property + def erlaubte_audiences(self) -> tuple[str, ...]: + """Alle aud-Werte, die ein ID-Token dieses Anbieters tragen darf.""" + return (self.client_id, *self.native_client_ids) + + +def _wahrheitswert(rohwert: str | None, standard: bool) -> bool: + if rohwert is None or not rohwert.strip(): + return standard + return rohwert.strip().lower() in {"1", "true", "yes", "on", "ja"} + + +def _liste(rohwert: str | None) -> tuple[str, ...]: + if not rohwert: + return () + return tuple(t.strip() for t in rohwert.replace(";", ",").split(",") if t.strip()) + + +def _praefix(slug: str) -> str: + """"authentik" -> "OIDC_AUTHENTIK_", "my-idp" -> "OIDC_MY_IDP_".""" + return "OIDC_" + slug.upper().replace("-", "_") + "_" + + +def provider_aus_umgebung( + env: Mapping[str, str], public_base_url: str = "" +) -> list[OidcProvider]: + """Liest alle konfigurierten Anbieter. + + Unvollstaendig konfigurierte Anbieter werden uebersprungen und protokolliert – + ein Tippfehler in der ``.env`` darf den Serverstart nicht verhindern. + """ + anbieter: list[OidcProvider] = [] + basis = public_base_url.rstrip("/") + + for roh in _liste(env.get("OIDC_PROVIDERS")): + slug = roh.strip().lower() + if not _ERLAUBTES_KUERZEL.match(slug): + logger.warning( + "OIDC-Anbieter '%s' uebersprungen: Kuerzel darf nur Kleinbuchstaben, " + "Ziffern und Bindestriche enthalten.", + roh, + ) + continue + if any(a.slug == slug for a in anbieter): + logger.warning("OIDC-Anbieter '%s' doppelt genannt – zweiter ignoriert.", slug) + continue + + p = _praefix(slug) + discovery = (env.get(p + "DISCOVERY_URL") or "").strip() + client_id = (env.get(p + "CLIENT_ID") or "").strip() + if not discovery or not client_id: + logger.warning( + "OIDC-Anbieter '%s' uebersprungen: %sDISCOVERY_URL und %sCLIENT_ID " + "muessen gesetzt sein.", + slug, + p, + p, + ) + continue + + redirect = (env.get(p + "REDIRECT_URI") or "").strip() + if not redirect: + if not basis: + logger.warning( + "OIDC-Anbieter '%s' uebersprungen: ohne PUBLIC_BASE_URL laesst sich " + "die Rueckleit-Adresse nicht bilden (oder %sREDIRECT_URI setzen).", + slug, + p, + ) + continue + # Das /api-Praefix entsteht erst in nginx, nicht in FastAPI – deshalb + # gehoert es hier fest hinein und darf nicht aus dem Request kommen. + redirect = f"{basis}/api/auth/oidc/{slug}/callback" + + def _scopes(schluessel: str, standard: str) -> str: + wert = (env.get(p + schluessel) or "").strip() or standard + # Ohne openid ist es kein OpenID Connect, sondern blosses OAuth – + # dann kaeme gar kein ID-Token zurueck. + return wert if "openid" in wert.split() else "openid " + wert + + scopes = _scopes("SCOPES", STANDARD_SCOPES) + native_scopes = _scopes("NATIVE_SCOPES", STANDARD_NATIVE_SCOPES) + + anbieter.append( + OidcProvider( + slug=slug, + display_name=(env.get(p + "DISPLAY_NAME") or "").strip() or slug.capitalize(), + discovery_url=discovery, + client_id=client_id, + client_secret=(env.get(p + "CLIENT_SECRET") or "").strip(), + scopes=scopes, + native_scopes=native_scopes, + native_client_ids=_liste(env.get(p + "NATIVE_CLIENT_IDS")), + native_redirect_uri=(env.get(p + "NATIVE_REDIRECT_URI") or "").strip() + or STANDARD_NATIVE_REDIRECT, + redirect_uri=redirect, + auto_create=_wahrheitswert(env.get(p + "AUTO_CREATE"), True), + require_verified_email=_wahrheitswert( + env.get(p + "REQUIRE_VERIFIED_EMAIL"), True + ), + ) + ) + + return anbieter + + +@lru_cache +def registrierte_provider() -> tuple[OidcProvider, ...]: + """Die Anbieter dieses Prozesses (einmal beim ersten Zugriff gelesen).""" + from .config import get_settings + + return tuple(provider_aus_umgebung(os.environ, get_settings().public_base_url)) + + +def provider_finden(slug: str) -> OidcProvider | None: + for p in registrierte_provider(): + if p.slug == slug: + return p + return None diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py new file mode 100644 index 0000000..9d7a667 --- /dev/null +++ b/backend/app/routers/oidc.py @@ -0,0 +1,333 @@ +"""Anmeldung ueber einen OpenID-Connect-Anbieter (z.B. Authentik). + +Zwei Wege, ein Ergebnis: In beiden Faellen endet die Anmeldung mit einem ganz +normalen Vorrania-Token aus :func:`create_access_token`. Dadurch bleiben +``deps.py``, die Rollenpruefung und die langlebigen API-Tokens unveraendert – +der Anbieter ist ausschliesslich fuer den Anmeldevorgang zustaendig. + +* **Browser** (vertraulicher Client): ``/login`` leitet zum Anbieter, + ``/callback`` loest den Code mit dem Client-Secret ein. +* **App** (oeffentlicher Client, PKCE): die App fuehrt den Flow mit AppAuth im + System-Browser selbst durch und reicht nur ihr ID-Token bei ``/native`` ein. +""" + +from __future__ import annotations + +import logging +import secrets +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import User, UserIdentity +from ..oidc_config import OidcProvider, provider_finden, registrierte_provider +from ..schemas import ( + OidcIdentityOut, + OidcLinkStart, + OidcNativeLogin, + OidcProviderOut, + Token, +) +from ..security import create_access_token +from ..services import oidc as dienst +from ..services import oidc_state + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth/oidc", tags=["auth"]) + +# Wohin die Weboberflaeche nach dem Rueckweg geschickt wird. Absichtlich relativ: +# der Browser loest das gegen die oeffentliche Adresse auf, mit der er ohnehin +# gerade spricht – eine korrekt gesetzte PUBLIC_BASE_URL braucht es dafuer nicht. +WEB_ERFOLG = "/auth/callback" +WEB_FEHLER = "/login" +# Eine Verknuepfung endet dort, wo sie angestossen wurde – nicht auf der +# Anmeldemaske: Der Nutzer ist ja die ganze Zeit angemeldet geblieben. +WEB_KONTO = "/konto" + + +def _provider_oder_404(slug: str) -> OidcProvider: + provider = provider_finden(slug) + if provider is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, f"Kein Anmeldeanbieter '{slug}' eingerichtet" + ) + return provider + + +def _token_fuer(user: User) -> Token: + token = create_access_token(subject=user.username, role=user.role.value) + return Token(access_token=token, role=user.role, username=user.username) + + +@router.get("/providers", response_model=list[OidcProviderOut]) +def anbieter_auflisten() -> list[OidcProviderOut]: + """Welche Anmeldeanbieter eingerichtet sind – ohne Anmeldung abrufbar. + + Die Weboberflaeche kennt keine Build-Variablen und die App muss die Angaben + je Serverprofil erfragen; beide erfahren erst hier, ob es ueberhaupt einen + SSO-Knopf zu zeigen gibt. Client-Secrets stehen hier nie drin. + """ + return [ + OidcProviderOut( + slug=p.slug, + display_name=p.display_name, + discovery_url=p.discovery_url, + native_client_id=p.native_client_ids[0] if p.native_client_ids else None, + native_redirect_uri=p.native_redirect_uri, + scopes=p.native_scopes, + ) + for p in registrierte_provider() + ] + + +@router.get("/{slug}/login") +def anmeldung_starten(slug: str, db: Session = Depends(get_db)) -> RedirectResponse: + """Schickt den Browser zum Anbieter.""" + provider = _provider_oder_404(slug) + # Abgebrochene Anmeldungen hinterlassen Zettel – hier ist der guenstigste + # Moment, sie loszuwerden. + oidc_state.aufraeumen(db) + + zettel = oidc_state.anlegen(db, provider.slug) + try: + ziel = dienst.anmelde_url( + provider, + state=zettel.state, + nonce=zettel.nonce, + code_verifier=zettel.code_verifier, + ) + except dienst.OidcFehler as fehler: + logger.warning("SSO '%s' konnte nicht gestartet werden: %s", slug, fehler) + return _fehler_weiterleitung(fehler.schluessel) + + return RedirectResponse(ziel, status_code=status.HTTP_303_SEE_OTHER) + + +def _fehler_weiterleitung(schluessel: str, verknuepfung: bool = False) -> RedirectResponse: + # Nur die Kennung, kein Klartext: die Oberflaeche uebersetzt sie, und interne + # Details bleiben im Serverprotokoll. + ziel = WEB_KONTO if verknuepfung else WEB_FEHLER + return RedirectResponse( + f"{ziel}#sso_error={quote(schluessel)}", status_code=status.HTTP_303_SEE_OTHER + ) + + +@router.get("/{slug}/callback") +def anmeldung_abschliessen( + slug: str, + db: Session = Depends(get_db), + code: str | None = None, + state: str | None = None, + error: str | None = None, +) -> RedirectResponse: + """Rueckweg vom Anbieter. + + Zwei Ausgaenge, je nachdem womit der Vorgang gestartet wurde: eine Anmeldung + endet mit einem Vorrania-Token, eine Verknuepfung haengt die Kennung an das + bereits angemeldete Konto und laesst die Sitzung unberuehrt. + """ + provider = _provider_oder_404(slug) + + if error: + # Nutzer hat abgebrochen oder der Anbieter verweigert – kein Serverfehler. + # Zu diesem Zeitpunkt ist noch unbekannt, ob es eine Verknuepfung war; + # die Anmeldemaske ist der unschaedlichere Landeplatz. + logger.info("SSO '%s' vom Anbieter abgelehnt: %s", slug, error) + return _fehler_weiterleitung("abgebrochen") + + if not code or not state: + return _fehler_weiterleitung("unvollstaendige_antwort") + + # Schutz vor untergeschobenen Anmeldungen: der Zettel muss von uns stammen, + # darf nicht abgelaufen und noch nicht verbraucht sein. Das Einloesen + # entfernt ihn, ein zweiter Rueckweg laeuft also ins Leere. + gemerkt = oidc_state.einloesen(db, state) + if gemerkt is None: + logger.warning("SSO '%s': unbekannter oder abgelaufener state.", slug) + return _fehler_weiterleitung("sitzung_abgelaufen") + if not secrets.compare_digest(gemerkt.provider, slug): + logger.warning("SSO '%s': state gehoert zu Anbieter '%s'.", slug, gemerkt.provider) + return _fehler_weiterleitung("state_falsch") + + verknuepfen_fuer = gemerkt.link_user_id + + try: + tokens = dienst.code_einloesen(provider, code=code, code_verifier=gemerkt.code_verifier) + roh_id_token = tokens.get("id_token") + if not roh_id_token: + raise dienst.OidcFehler( + "kein_id_token", "Der Anbieter hat kein ID-Token geliefert." + ) + claims = dienst.pruefe_id_token( + provider, + roh_id_token, + nonce=gemerkt.nonce, + # Der Browser-Weg laeuft ueber den vertraulichen Client – ein Token + # fuer die App-Client-ID darf hier nicht gelten. + erlaubte_audiences=(provider.client_id,), + ) + + if verknuepfen_fuer is not None: + # Verknuepfung: Es wird niemand angemeldet. Wer das Konto ist, stand + # schon beim Start fest (aus der damaligen Sitzung) – die Claims + # liefern nur die Kennung, der wir es zuordnen. + konto = db.get(User, verknuepfen_fuer) + if konto is None: + raise dienst.OidcFehler( + "konto_verschwunden", "Das zu verknuepfende Konto gibt es nicht mehr." + ) + dienst.identitaet_verknuepfen(db, konto, provider, claims) + logger.info("SSO '%s': Verknuepfung fuer '%s' abgeschlossen.", slug, konto.username) + return RedirectResponse( + f"{WEB_KONTO}#sso_linked={quote(provider.slug)}", + status_code=status.HTTP_303_SEE_OTHER, + ) + + nutzer = dienst.benutzer_aus_claims(db, provider, claims) + except dienst.OidcFehler as fehler: + logger.warning("SSO '%s' gescheitert: %s", slug, fehler) + return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None) + + token = _token_fuer(nutzer) + logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username) + # Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht + # an den Server geschickt und landen damit auch nicht in den nginx-Logs. + return RedirectResponse( + f"{WEB_ERFOLG}#token={quote(token.access_token)}", + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.post("/{slug}/native", response_model=Token) +def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get_db)) -> Token: + """Tauscht das ID-Token aus der App gegen ein Vorrania-Token. + + Die App hat den Flow mit PKCE im System-Browser selbst gefuehrt; der Server + prueft hier nur noch, dass das Token echt, unverfallen und **fuer einen der + registrierten App-Clients ausgestellt** ist. + """ + provider = _provider_oder_404(slug) + if not provider.native_client_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Fuer '{slug}' ist kein App-Client eingerichtet", + ) + try: + claims = dienst.pruefe_id_token( + provider, + payload.id_token, + # AppAuth prueft die nonce bereits auf dem Geraet. Schickt die App sie + # mit, prueft der Server sie ein zweites Mal – erst das schliesst aus, + # dass ein anderswo abgegriffenes ID-Token desselben Clients hier + # eingereicht wird. + nonce=payload.nonce, + erlaubte_audiences=provider.native_client_ids, + ) + nutzer = dienst.benutzer_aus_claims(db, provider, claims) + except dienst.OidcFehler as fehler: + logger.warning("SSO '%s' (App) gescheitert: %s", slug, fehler) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler + + logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username) + return _token_fuer(nutzer) + + +# --------------------------------------------------------------------------- +# Verknuepfung eines bestehenden Kontos (fuer bereits angemeldete Nutzer) +# --------------------------------------------------------------------------- + + +@router.get("/identities", response_model=list[OidcIdentityOut]) +def verknuepfungen_auflisten( + current_user: User = Depends(get_current_user), db: Session = Depends(get_db) +) -> list[OidcIdentityOut]: + """Mit welchen Anmeldediensten das eigene Konto verknuepft ist.""" + eintraege = ( + db.query(UserIdentity) + .filter(UserIdentity.user_id == current_user.id) + .order_by(UserIdentity.provider) + .all() + ) + return [ + OidcIdentityOut( + provider=e.provider, + # Ein inzwischen abgeschalteter Anbieter soll trotzdem sichtbar + # bleiben – sonst haengt eine Verknuepfung unsichtbar am Konto. + display_name=(p.display_name if (p := provider_finden(e.provider)) else e.provider), + created_at=e.created_at, + last_login_at=e.last_login_at, + ) + for e in eintraege + ] + + +@router.post("/{slug}/link", response_model=OidcLinkStart) +def verknuepfung_starten( + slug: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> OidcLinkStart: + """Startet die Verknuepfung des eigenen Kontos mit einem Anmeldedienst. + + Bewusst POST und nicht einfach ein Link: Die Berechtigung steckt im + Bearer-Token der laufenden Sitzung, und das laesst sich bei einer normalen + Seitennavigation nicht mitschicken. Die Oberflaeche holt hier also erst die + Adresse und leitet dann selbst dorthin weiter. + + Damit braucht die Verknuepfung weder eine bestaetigte E-Mail-Adresse noch + einen Abgleich ueber den Benutzernamen – beides waere eine Uebernahmeflaeche. + """ + provider = _provider_oder_404(slug) + + schon_da = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == provider.slug, UserIdentity.user_id == current_user.id) + .first() + ) + if schon_da is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"Dein Konto ist bereits mit '{provider.display_name}' verknüpft", + ) + + oidc_state.aufraeumen(db) + zettel = oidc_state.anlegen(db, provider.slug, link_user_id=current_user.id) + try: + ziel = dienst.anmelde_url( + provider, + state=zettel.state, + nonce=zettel.nonce, + code_verifier=zettel.code_verifier, + ) + except dienst.OidcFehler as fehler: + logger.warning("SSO '%s': Verknuepfung nicht startbar: %s", slug, fehler) + raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(fehler)) from fehler + + return OidcLinkStart(authorization_url=ziel) + + +# response_model=None ist noetig, weil "from __future__ import annotations" +# aus "-> None" den Typ NoneType macht - den FastAPI sonst als Antwortkoerper +# deutet und mit 204 nicht vereinbar ist. +@router.delete("/{slug}/link", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def verknuepfung_loesen( + slug: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + """Loest die Verknuepfung wieder. + + Der Anbieter muss dafuer NICHT mehr eingerichtet sein – sonst liesse sich + eine Altlast nach dem Abschalten von SSO nicht mehr entfernen. + """ + try: + dienst.identitaet_loesen(db, current_user, slug) + except dienst.OidcFehler as fehler: + # "letzte Anmeldemoeglichkeit" ist kein Serverfehler, sondern eine + # bewusste Weigerung – der Text erklaert den Ausweg. + raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 074de6f..ddc6fc4 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func from sqlalchemy.orm import Session from ..database import get_db @@ -10,6 +11,30 @@ from ..security import hash_password router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)]) +def _email_normalisiert(roh: str | None) -> str | None: + """Leereingabe heisst „keine Adresse"; gespeichert wird immer kleingeschrieben. + + Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal + uebernimmt – deshalb darf sie nicht zweimal vergeben sein. + """ + if roh is None: + return None + sauber = roh.strip().lower() + return sauber or None + + +def _email_pruefen(db: Session, email: str | None, ausser_id: int | None = None) -> None: + if email is None: + return + frage = db.query(User).filter(func.lower(User.email) == email) + if ausser_id is not None: + frage = frage.filter(User.id != ausser_id) + if frage.first() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben" + ) + + @router.get("", response_model=list[UserOut]) def list_users(db: Session = Depends(get_db)) -> list[User]: return db.query(User).order_by(User.username).all() @@ -19,9 +44,12 @@ def list_users(db: Session = Depends(get_db)) -> list[User]: def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User: if db.query(User).filter(User.username == payload.username).first(): raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits") + email = _email_normalisiert(payload.email) + _email_pruefen(db, email) user = User( username=payload.username, password_hash=hash_password(payload.password), + email=email, role=payload.role, ) db.add(user) @@ -39,6 +67,10 @@ def update_user( raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") if payload.password is not None: user.password_hash = hash_password(payload.password) + if payload.email is not None: + email = _email_normalisiert(payload.email) + _email_pruefen(db, email, ausser_id=user.id) + user.email = email if payload.role is not None: user.role = payload.role db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6a9f63f..a412a72 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -93,6 +93,7 @@ class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int username: str + email: str | None = None role: Role created_at: datetime @@ -100,14 +101,69 @@ class UserOut(BaseModel): class UserCreate(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=4, max_length=255) + email: str | None = Field(default=None, max_length=255) role: Role = Role.user class UserUpdate(BaseModel): password: str | None = Field(default=None, min_length=4, max_length=255) + # Leerer String heisst „Adresse entfernen" – deshalb hier nicht min_length. + email: str | None = Field(default=None, max_length=255) role: Role | None = None +# ---- Single Sign-on (OpenID Connect) ---- +class OidcProviderOut(BaseModel): + """Was ein Client braucht, um den Anmeldeknopf zu zeigen und den Flow zu starten. + + Bewusst oeffentlich abrufbar: die Web-Oberflaeche kennt keine Build-Variablen, + und die iOS-App muss die Angaben je Serverprofil vom jeweiligen Server lernen. + Ein Client-Secret steht hier nie drin. + """ + + slug: str + display_name: str + discovery_url: str + # Client-ID fuer die App (oeffentlicher Client, PKCE). Leer, wenn fuer diesen + # Anbieter kein nativer Client eingerichtet ist. + native_client_id: str | None = None + native_redirect_uri: str + # Scopes fuer den App-Weg (inkl. offline_access) – der Browser-Weg wird vom + # Server selbst gebaut und braucht diese Angabe nicht. + scopes: str + + +class OidcIdentityOut(BaseModel): + """Eine bestehende Verknuepfung des angemeldeten Kontos.""" + + model_config = ConfigDict(from_attributes=True) + provider: str + display_name: str + created_at: datetime + last_login_at: datetime | None = None + + +class OidcLinkStart(BaseModel): + """Adresse, zu der die Oberflaeche fuer die Verknuepfung weiterleiten soll. + + Der Start laeuft ueber POST und nicht als einfacher Link, weil die + Berechtigung aus dem Bearer-Token der laufenden Sitzung kommt – und das + laesst sich bei einer normalen Seitennavigation nicht mitschicken. + """ + + authorization_url: str + + +class OidcNativeLogin(BaseModel): + """ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird.""" + + id_token: str = Field(min_length=1) + # Die nonce, die die App in ihrer Anfrage verwendet hat. AppAuth prueft sie + # zwar selbst, aber nur der Server-Vergleich schliesst aus, dass ein anderswo + # abgegriffenes ID-Token desselben Clients hier eingereicht wird. + nonce: str | None = None + + # ---- Groups ---- class LocationMinStockIn(BaseModel): """Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten). diff --git a/backend/app/security.py b/backend/app/security.py index 85de286..a8048f4 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -18,7 +18,12 @@ def hash_password(password: str) -> str: return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8") -def verify_password(password: str, password_hash: str) -> bool: +def verify_password(password: str, password_hash: str | None) -> bool: + # Konten, die es nur ueber Single Sign-on gibt, haben gar keinen Hash. Ohne + # diesen Riegel liefe None.encode() in einen AttributeError – und der Login + # antwortete mit 500 statt mit einem ehrlichen 401. + if not password_hash: + return False try: pw = password.encode("utf-8")[:_BCRYPT_MAX_BYTES] return bcrypt.checkpw(pw, password_hash.encode("utf-8")) diff --git a/backend/app/services/oidc.py b/backend/app/services/oidc.py new file mode 100644 index 0000000..b4dcbbb --- /dev/null +++ b/backend/app/services/oidc.py @@ -0,0 +1,466 @@ +"""OpenID Connect: Discovery, Token-Pruefung und Zuordnung zu Vorrania-Konten. + +Der Protokoll-Teil laeuft ueber Authlib – Signaturpruefung, PKCE und +Code-Tausch sind nichts, was man selbst schreiben sollte. Bewusst die +SYNCHRONE Variante von Authlib: alle Routen dieses Projekts sind normale +``def``-Funktionen im Threadpool, die Datenbank-Sitzung ist synchron, und +ausgehendes HTTP laeuft schon in ``off.py`` ueber ein synchrones ``httpx``. + +:func:`benutzer_aus_claims` ist absichtlich frei von HTTP: die gesamte +Zuordnungs- und Anlage-Logik laesst sich damit mit einer Datenbank-Sitzung und +einem selbstgebauten Claims-Wörterbuch pruefen. +""" + +from __future__ import annotations + +import logging +import re +import time +from datetime import datetime, timezone +from typing import Any + +import httpx +from authlib.integrations.httpx_client import OAuth2Client +from authlib.jose import JsonWebKey, jwt +from authlib.jose.errors import JoseError +from authlib.oidc.core import CodeIDToken +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..models import Role, User, UserIdentity +from ..oidc_config import OidcProvider + +logger = logging.getLogger(__name__) + +TIMEOUT_SECONDS = 10.0 +# Discovery-Dokument und Schluessel aendern sich selten; eine Stunde Vorrat spart +# bei jeder Anmeldung zwei Abfragen beim Anbieter. +CACHE_SEKUNDEN = 3600.0 +# Etwas Spielraum fuer auseinanderlaufende Uhren zwischen Server und Anbieter. +UHREN_SPIELRAUM = 60 + + +class OidcFehler(Exception): + """Anmeldung ueber den Anbieter ist gescheitert. + + ``schluessel`` landet als Kennung in der Weiterleitung zur Web-Oberflaeche, + ``args[0]`` ist der Klartext fuer Protokoll und API-Antwort. + """ + + def __init__(self, schluessel: str, meldung: str) -> None: + super().__init__(meldung) + self.schluessel = schluessel + + +_metadaten_cache: dict[str, tuple[float, dict[str, Any]]] = {} +_jwks_cache: dict[str, tuple[float, Any]] = {} + + +def cache_leeren() -> None: + """Nur fuer Tests und zum erzwungenen Neuladen.""" + _metadaten_cache.clear() + _jwks_cache.clear() + + +def _holen(url: str) -> dict[str, Any]: + try: + antwort = httpx.get(url, timeout=TIMEOUT_SECONDS, follow_redirects=True) + antwort.raise_for_status() + return antwort.json() + except (httpx.HTTPError, ValueError) as fehler: + # Anders als beim Produkt-Lookup darf das hier NICHT still zu None werden – + # ein nicht erreichbarer Anbieter muss als Fehler sichtbar sein. + raise OidcFehler( + "anbieter_nicht_erreichbar", + f"Anmeldeanbieter nicht erreichbar ({url}): {fehler}", + ) from fehler + + +def metadaten(provider: OidcProvider, erzwingen: bool = False) -> dict[str, Any]: + """Discovery-Dokument des Anbieters (gepuffert).""" + eintrag = _metadaten_cache.get(provider.slug) + if not erzwingen and eintrag and eintrag[0] > time.monotonic(): + return eintrag[1] + + daten = _holen(provider.discovery_url) + for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"): + if not daten.get(pflicht): + raise OidcFehler( + "discovery_unvollstaendig", + f"Discovery-Dokument von '{provider.slug}' ohne '{pflicht}'.", + ) + _metadaten_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, daten) + return daten + + +def _jwks(provider: OidcProvider, erzwingen: bool = False) -> Any: + eintrag = _jwks_cache.get(provider.slug) + if not erzwingen and eintrag and eintrag[0] > time.monotonic(): + return eintrag[1] + + schluessel = JsonWebKey.import_key_set(_holen(metadaten(provider)["jwks_uri"])) + _jwks_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, schluessel) + return schluessel + + +def _client(provider: OidcProvider, redirect_uri: str) -> OAuth2Client: + return OAuth2Client( + client_id=provider.client_id, + # Die App ist ein oeffentlicher Client und schickt nie ein Secret; nur der + # vertrauliche Web-Client hat eins. + client_secret=provider.client_secret or None, + scope=provider.scopes, + redirect_uri=redirect_uri, + code_challenge_method="S256", + timeout=TIMEOUT_SECONDS, + ) + + +def anmelde_url(provider: OidcProvider, state: str, nonce: str, code_verifier: str) -> str: + """Adresse, zu der der Browser fuer die Anmeldung geschickt wird.""" + meta = metadaten(provider) + with _client(provider, provider.redirect_uri) as client: + url, _ = client.create_authorization_url( + meta["authorization_endpoint"], + state=state, + nonce=nonce, + code_verifier=code_verifier, + ) + return url + + +def code_einloesen(provider: OidcProvider, code: str, code_verifier: str) -> dict[str, Any]: + """Tauscht den Autorisierungs-Code gegen Tokens (vertraulicher Web-Client).""" + meta = metadaten(provider) + try: + with _client(provider, provider.redirect_uri) as client: + return client.fetch_token( + meta["token_endpoint"], + grant_type="authorization_code", + code=code, + code_verifier=code_verifier, + redirect_uri=provider.redirect_uri, + ) + except OidcFehler: + raise + except Exception as fehler: # Authlib wirft je nach Ursache sehr Verschiedenes. + raise OidcFehler( + "code_tausch_fehlgeschlagen", f"Code-Tausch gescheitert: {fehler}" + ) from fehler + + +def pruefe_id_token( + provider: OidcProvider, + id_token: str, + nonce: str | None = None, + erlaubte_audiences: tuple[str, ...] | None = None, +) -> dict[str, Any]: + """Prueft Signatur, ``iss``, ``aud``, ``exp`` (und ``nonce``, falls bekannt). + + ``erlaubte_audiences`` schraenkt die zulaessigen Empfaenger ein: der + Web-Rueckweg akzeptiert nur die vertrauliche Client-ID, der App-Weg nur die + der registrierten oeffentlichen Clients. + """ + meta = metadaten(provider) + audiences = erlaubte_audiences or provider.erlaubte_audiences + if not audiences: + raise OidcFehler( + "keine_audience", f"Fuer '{provider.slug}' ist keine Client-ID hinterlegt." + ) + + optionen = { + "iss": {"essential": True, "value": meta["issuer"]}, + "aud": {"essential": True, "values": list(audiences)}, + "exp": {"essential": True}, + "sub": {"essential": True}, + } + params = {"nonce": nonce} if nonce else None + + def _entschluesseln(schluessel: Any) -> Any: + return jwt.decode( + id_token, + key=schluessel, + claims_cls=CodeIDToken, + claims_options=optionen, + claims_params=params, + ) + + try: + try: + claims = _entschluesseln(_jwks(provider)) + except JoseError: + # Kann ein frisch gedrehter Signaturschluessel sein – einmal neu laden. + claims = _entschluesseln(_jwks(provider, erzwingen=True)) + claims.validate(leeway=UHREN_SPIELRAUM) + except JoseError as fehler: + raise OidcFehler("id_token_ungueltig", f"ID-Token abgelehnt: {fehler}") from fehler + + # Doppelt gehalten: aud steht schon in den claims_options, aber ein falsch + # adressiertes Token darf unter keinen Umstaenden durchrutschen. + roh_aud = claims.get("aud") + token_aud = set(roh_aud) if isinstance(roh_aud, list) else {roh_aud} + if not token_aud & set(audiences): + raise OidcFehler("id_token_ungueltig", "ID-Token ist fuer einen anderen Client bestimmt.") + + return dict(claims) + + +# --------------------------------------------------------------------------- +# Zuordnung zu Vorrania-Konten (ohne HTTP – direkt testbar) +# --------------------------------------------------------------------------- + +_ERLAUBT_IM_NAMEN = re.compile(r"[^A-Za-z0-9._-]") + + +def _jetzt() -> datetime: + return datetime.now(timezone.utc) + + +def _namensvorschlag(claims: dict[str, Any]) -> str: + roh = ( + claims.get("preferred_username") + or claims.get("nickname") + or (claims.get("email") or "").split("@")[0] + or claims.get("name") + or "" + ) + sauber = _ERLAUBT_IM_NAMEN.sub("", str(roh).strip().replace(" ", "."))[:64].strip("._-") + return sauber or "sso-nutzer" + + +def _freier_name(db: Session, vorschlag: str) -> str: + """Macht den Namen eindeutig, ohne je ein bestehendes Konto zu uebernehmen.""" + if db.query(User).filter(User.username == vorschlag).first() is None: + return vorschlag + stamm = vorschlag[:58].rstrip("._-") or "sso-nutzer" + for n in range(2, 1000): + kandidat = f"{stamm}-{n}" + if db.query(User).filter(User.username == kandidat).first() is None: + return kandidat + raise OidcFehler("kein_freier_name", "Kein freier Benutzername zu finden.") + + +def _email_aus_claims(claims: dict[str, Any]) -> str | None: + roh = claims.get("email") + if not roh or not isinstance(roh, str): + return None + return roh.strip().lower() or None + + +def identitaet_verknuepfen( + db: Session, nutzer: User, provider: OidcProvider, claims: dict[str, Any] +) -> UserIdentity: + """Haengt eine Anmeldedienst-Kennung an ein bestehendes, angemeldetes Konto. + + Der Weg fuer alle, deren Konto keine (oder eine andere) E-Mail-Adresse hat: + Die Berechtigung kommt hier aus der laufenden Vorrania-Sitzung, nicht aus + einem Claim – deshalb braucht es weder eine bestaetigte Adresse noch einen + Abgleich ueber den Benutzernamen. + """ + subject = str(claims.get("sub") or "").strip() + if not subject: + raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.") + + vorhanden = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == provider.slug, UserIdentity.subject == subject) + .first() + ) + if vorhanden is not None: + if vorhanden.user_id == nutzer.id: + return vorhanden # schon verknuepft – nichts zu tun + # Diese Kennung gehoert einem ANDEREN Vorrania-Konto. Umhaengen waere + # eine Uebernahme, auch wenn hier gerade jemand angemeldet ist. + raise OidcFehler( + "kennung_fremd", + "Diese Anmeldung ist bereits mit einem anderen Vorrania-Konto verknuepft.", + ) + + schon_dabei = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == provider.slug, UserIdentity.user_id == nutzer.id) + .first() + ) + if schon_dabei is not None: + raise OidcFehler( + "schon_verknuepft", + f"Dieses Konto ist bereits mit '{provider.display_name}' verknuepft.", + ) + + identitaet = UserIdentity( + user_id=nutzer.id, + provider=provider.slug, + subject=subject, + last_login_at=_jetzt(), + ) + db.add(identitaet) + + # Adresse nachtragen, solange sie frei ist – dann findet auch die + # Erstverknuepfung anderer Konten spaeter das Richtige. + email = _email_aus_claims(claims) + if email and not nutzer.email: + if db.query(User).filter(func.lower(User.email) == email).first() is None: + nutzer.email = email + + db.commit() + db.refresh(identitaet) + logger.info("SSO '%s': mit Konto '%s' verknuepft.", provider.slug, nutzer.username) + return identitaet + + +def identitaet_loesen(db: Session, nutzer: User, provider_slug: str) -> None: + """Entfernt eine Verknuepfung – aber nie die letzte Anmeldemoeglichkeit.""" + identitaet = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == provider_slug, UserIdentity.user_id == nutzer.id) + .first() + ) + if identitaet is None: + raise OidcFehler("nicht_verknuepft", "Diese Verknuepfung besteht nicht.") + + uebrige = ( + db.query(UserIdentity) + .filter(UserIdentity.user_id == nutzer.id, UserIdentity.id != identitaet.id) + .count() + ) + if not nutzer.password_hash and uebrige == 0: + # Ohne Passwort und ohne weitere Kennung waere das Konto danach von + # niemandem mehr erreichbar – auch nicht vom Besitzer. + raise OidcFehler( + "letzte_anmeldung", + "Das ist die einzige Anmeldemoeglichkeit dieses Kontos. Vergib zuerst " + "ein Passwort oder verknuepfe einen weiteren Anmeldedienst.", + ) + + db.delete(identitaet) + db.commit() + logger.info("SSO '%s': Verknuepfung von '%s' geloest.", provider_slug, nutzer.username) + + +def benutzer_aus_claims( + db: Session, provider: OidcProvider, claims: dict[str, Any] +) -> User: + """Findet oder erzeugt das Vorrania-Konto zu einem geprueften ID-Token. + + Reihenfolge: + + 1. Bekannte Identitaet (Anbieter + ``sub``) – der Normalfall ab dem zweiten Mal. + 2. Erstverknuepfung ueber die **bestaetigte** E-Mail-Adresse. + 3. Neues Konto, falls fuer den Anbieter erlaubt. + """ + subject = str(claims.get("sub") or "").strip() + if not subject: + raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.") + + email = _email_aus_claims(claims) + # Authentik liefert das als echtes bool; andere Anbieter schicken schon mal + # den String "true". + roh_bestaetigt = claims.get("email_verified") + email_bestaetigt = roh_bestaetigt is True or str(roh_bestaetigt).lower() == "true" + + # 1) Bereits verknuepft. + identitaet = ( + db.query(UserIdentity) + .filter(UserIdentity.provider == provider.slug, UserIdentity.subject == subject) + .first() + ) + if identitaet is not None: + nutzer = db.get(User, identitaet.user_id) + if nutzer is None: + # Verwaiste Verknuepfung (Konto geloescht) – aufraeumen und neu entscheiden. + db.delete(identitaet) + db.commit() + else: + identitaet.last_login_at = _jetzt() + # Adresse nachtragen, falls das Konto noch keine hat – aber nur, wenn + # sie nicht schon einem anderen Konto gehoert (eindeutiger Index). + if email and not nutzer.email: + if db.query(User).filter(func.lower(User.email) == email).first() is None: + nutzer.email = email + db.commit() + db.refresh(nutzer) + return nutzer + + # 2) Erstverknuepfung ueber die E-Mail. + if email and (email_bestaetigt or not provider.require_verified_email): + treffer = db.query(User).filter(func.lower(User.email) == email).first() + if treffer is not None: + belegt = ( + db.query(UserIdentity) + .filter( + UserIdentity.provider == provider.slug, + UserIdentity.user_id == treffer.id, + ) + .first() + ) + if belegt is not None: + # Dasselbe Konto haengt schon an einer ANDEREN Kennung dieses + # Anbieters. Stillschweigend umhaengen waere eine Uebernahme. + raise OidcFehler( + "konto_bereits_verknuepft", + f"Das Konto '{treffer.username}' ist bereits mit einer anderen " + f"Kennung bei '{provider.slug}' verknuepft.", + ) + db.add( + UserIdentity( + user_id=treffer.id, + provider=provider.slug, + subject=subject, + last_login_at=_jetzt(), + ) + ) + db.commit() + db.refresh(treffer) + return treffer + elif email and not email_bestaetigt: + logger.info( + "SSO '%s': E-Mail '%s' ist beim Anbieter nicht bestaetigt – keine " + "Verknuepfung mit einem bestehenden Konto.", + provider.slug, + email, + ) + + # 3) Neu anlegen, falls erlaubt. + if not provider.auto_create: + raise OidcFehler( + "kein_konto", + "Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss " + "es zuerst anlegen und die E-Mail-Adresse hinterlegen.", + ) + + # Die Adresse ist eindeutig indiziert. Sie kann hier trotzdem schon vergeben + # sein – naemlich wenn Schritt 2 sie wegen fehlender Bestaetigung NICHT zum + # Verknuepfen benutzen durfte. Dann bleibt das neue Konto ohne Adresse, statt + # den Login an einem Datenbankfehler scheitern zu lassen. + email_frei = email is not None and ( + db.query(User).filter(func.lower(User.email) == email).first() is None + ) + if email and not email_frei: + logger.info( + "SSO '%s': Adresse '%s' ist schon vergeben – neues Konto bleibt ohne Adresse.", + provider.slug, + email, + ) + + # Rolle kommt NIE aus dem Anbieter: sonst genuegte ein passend gesetzter Claim, + # um sich Administratorrechte zu geben. + nutzer = User( + username=_freier_name(db, _namensvorschlag(claims)), + password_hash=None, + email=email if email_frei else None, + role=Role.user, + ) + db.add(nutzer) + db.flush() + db.add( + UserIdentity( + user_id=nutzer.id, + provider=provider.slug, + subject=subject, + last_login_at=_jetzt(), + ) + ) + db.commit() + db.refresh(nutzer) + logger.info("SSO '%s': neues Konto '%s' angelegt.", provider.slug, nutzer.username) + return nutzer diff --git a/backend/app/services/oidc_state.py b/backend/app/services/oidc_state.py new file mode 100644 index 0000000..5d8e9d5 --- /dev/null +++ b/backend/app/services/oidc_state.py @@ -0,0 +1,83 @@ +"""Kurzlebige Merkzettel fuer den Browser-Anmeldevorgang. + +``state``, ``nonce`` und der PKCE-Verifier muessen den Ausflug zum Anmeldedienst +ueberdauern. Sie liegen dafuer in der Datenbank statt in einem Cookie – warum, +steht bei :class:`~app.models.OidcLoginState`. + +Reine Datenbank-Funktionen, damit sie sich mit der vorhandenen ``db``-Fixture +pruefen lassen. +""" + +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta, timezone + +from sqlalchemy.orm import Session + +from ..models import OidcLoginState + +# Genug fuer eine Anmeldung samt Zwei-Faktor-Eingabe, kurz genug, dass ein +# liegengebliebener Zettel nicht ewig gilt. +GUELTIG = timedelta(minutes=15) + + +def _jetzt() -> datetime: + return datetime.now(timezone.utc) + + +def _mit_zeitzone(wert: datetime) -> datetime: + # SQLite gibt Zeitstempel ohne Zonenangabe zurueck; ein Vergleich mit einem + # bewussten Zeitpunkt wuerfe sonst einen TypeError. + return wert if wert.tzinfo is not None else wert.replace(tzinfo=timezone.utc) + + +def anlegen( + db: Session, provider: str, link_user_id: int | None = None +) -> OidcLoginState: + """Erzeugt state, nonce und PKCE-Verifier und merkt sie vor. + + ``link_user_id`` macht daraus einen Verknuepfungs- statt Anmeldevorgang. + """ + zettel = OidcLoginState( + state=secrets.token_urlsafe(32), + provider=provider, + nonce=secrets.token_urlsafe(24), + # RFC 7636 erlaubt 43-128 Zeichen; token_urlsafe(64) liefert 86. + code_verifier=secrets.token_urlsafe(64), + link_user_id=link_user_id, + created_at=_jetzt(), + ) + db.add(zettel) + db.commit() + return zettel + + +def einloesen(db: Session, state: str, jetzt: datetime | None = None) -> OidcLoginState | None: + """Holt den Zettel und entfernt ihn dabei – er gilt genau einmal. + + Damit ist ein zweiter Rueckweg mit demselben ``state`` wirkungslos. + """ + zettel = db.get(OidcLoginState, state) + if zettel is None: + return None + + # Vor dem Ablauf-Vergleich loeschen: auch ein abgelaufener Zettel ist + # verbraucht und soll nicht liegenbleiben. + db.delete(zettel) + db.commit() + + if _mit_zeitzone(zettel.created_at) + GUELTIG < (jetzt or _jetzt()): + return None + return zettel + + +def aufraeumen(db: Session, jetzt: datetime | None = None) -> int: + """Entfernt abgelaufene Zettel (abgebrochene Anmeldungen).""" + grenze = (jetzt or _jetzt()) - GUELTIG + alte = [z for z in db.query(OidcLoginState).all() if _mit_zeitzone(z.created_at) < grenze] + for zettel in alte: + db.delete(zettel) + if alte: + db.commit() + return len(alte) diff --git a/backend/requirements.txt b/backend/requirements.txt index 66e2575..0f92130 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,9 @@ pydantic==2.10.4 pydantic-settings==2.7.1 python-jose[cryptography]==3.3.0 bcrypt==4.2.1 +# Single Sign-on (OpenID Connect): Discovery, PKCE, Code-Tausch und +# ID-Token-Pruefung gegen den JWKS des Anbieters. +Authlib==1.4.1 python-multipart==0.0.20 httpx==0.28.1 python-dateutil==2.9.0.post0 diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py new file mode 100644 index 0000000..d19d089 --- /dev/null +++ b/backend/tests/test_oidc.py @@ -0,0 +1,412 @@ +"""Single Sign-on: Konfiguration lesen und Konten zuordnen. + +Beides laeuft ohne HTTP – die Anbieter-Konfiguration ist eine reine Funktion +ueber einem Umgebungs-Woerterbuch, und die Zuordnung bekommt ein fertiges +Claims-Woerterbuch, wie es nach der Token-Pruefung vorliegt. +""" + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.models import Role, User, UserIdentity +from app.oidc_config import OidcProvider, provider_aus_umgebung +from app.routers.auth import login +from app.services import oidc_state +from app.security import hash_password, verify_password +from app.services.oidc import ( + OidcFehler, + benutzer_aus_claims, + identitaet_loesen, + identitaet_verknuepfen, +) + +BASIS = "https://vorrania.example.com" + +VOLLE_UMGEBUNG = { + "OIDC_PROVIDERS": "authentik", + "OIDC_AUTHENTIK_DISPLAY_NAME": "Authentik", + "OIDC_AUTHENTIK_DISCOVERY_URL": "https://idp.example.com/application/o/v/.well-known/openid-configuration", + "OIDC_AUTHENTIK_CLIENT_ID": "web-client", + "OIDC_AUTHENTIK_CLIENT_SECRET": "geheim", + "OIDC_AUTHENTIK_NATIVE_CLIENT_IDS": "ios-client, ipad-client", +} + + +@pytest.fixture +def provider() -> OidcProvider: + return OidcProvider( + slug="authentik", + display_name="Authentik", + discovery_url="https://idp.example.com/.well-known/openid-configuration", + client_id="web-client", + client_secret="geheim", + native_client_ids=("ios-client",), + redirect_uri=f"{BASIS}/api/auth/oidc/authentik/callback", + ) + + +def claims(**felder) -> dict: + grund = {"sub": "abc-123", "email": "eva@example.com", "email_verified": True} + grund.update(felder) + return grund + + +# ---- Konfiguration -------------------------------------------------------- + + +def test_ohne_konfiguration_kein_anbieter(): + """Ohne OIDC_PROVIDERS bleibt alles beim Passwort-Login. + + Wichtig, weil update.sh bestehende .env-Dateien nicht nachtraegt – ein alter + Bestand darf durch das Update nicht ploetzlich beim Start scheitern. + """ + assert provider_aus_umgebung({}, BASIS) == [] + + +def test_anbieter_wird_vollstaendig_gelesen(): + (p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS) + assert p.slug == "authentik" + assert p.display_name == "Authentik" + assert p.client_id == "web-client" + assert p.client_secret == "geheim" + assert p.native_client_ids == ("ios-client", "ipad-client") + # Das /api-Praefix entsteht erst in nginx und muss deshalb hier drinstehen. + assert p.redirect_uri == f"{BASIS}/api/auth/oidc/authentik/callback" + # Der Browser-Weg braucht kein Refresh-Token – offline_access dort anzufragen + # loeste nur einen ueberfluessigen Zustimmungsdialog aus. + assert p.scopes == "openid profile email" + # Die App dagegen erneuert selbst und braucht es zwingend: ohne diesen Scope + # liefert Authentik seit 2024.2 gar kein Refresh-Token. + assert "offline_access" in p.native_scopes + assert p.erlaubte_audiences == ("web-client", "ios-client", "ipad-client") + + +def test_zweiter_anbieter_ist_reine_konfiguration(): + """Ein weiterer Anbieter darf keine Codeaenderung brauchen.""" + umgebung = dict(VOLLE_UMGEBUNG) + umgebung["OIDC_PROVIDERS"] = "authentik,keycloak" + umgebung["OIDC_KEYCLOAK_DISCOVERY_URL"] = "https://kc.example.com/.well-known/openid-configuration" + umgebung["OIDC_KEYCLOAK_CLIENT_ID"] = "kc-web" + + anbieter = provider_aus_umgebung(umgebung, BASIS) + assert [p.slug for p in anbieter] == ["authentik", "keycloak"] + assert anbieter[1].display_name == "Keycloak" # Vorgabe aus dem Kuerzel + + +def test_unvollstaendiger_anbieter_wird_uebersprungen(): + """Ein Tippfehler in der .env darf den Serverstart nicht verhindern.""" + umgebung = {"OIDC_PROVIDERS": "authentik"} # weder Discovery noch Client-ID + assert provider_aus_umgebung(umgebung, BASIS) == [] + + +def test_ohne_basisadresse_kein_anbieter(): + """Ohne PUBLIC_BASE_URL laesst sich die Rueckleit-Adresse nicht bilden.""" + assert provider_aus_umgebung(VOLLE_UMGEBUNG, "") == [] + + +def test_ausdrueckliche_rueckleitadresse_ersetzt_die_basisadresse(): + umgebung = dict(VOLLE_UMGEBUNG) + umgebung["OIDC_AUTHENTIK_REDIRECT_URI"] = "https://anders.example.com/api/auth/oidc/authentik/callback" + (p,) = provider_aus_umgebung(umgebung, "") + assert p.redirect_uri == "https://anders.example.com/api/auth/oidc/authentik/callback" + + +# ---- Zuordnung ------------------------------------------------------------ + + +def test_bekannte_identitaet_meldet_denselben_nutzer_an(db, provider): + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin) + db.add(eva) + db.flush() + db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123")) + db.commit() + + gefunden = benutzer_aus_claims(db, provider, claims()) + + assert gefunden.id == eva.id + assert gefunden.role == Role.admin # Rolle bleibt, was der Admin gesetzt hat + assert db.query(User).count() == 1 + + +def test_erstverknuepfung_ueber_bestaetigte_email(db, provider): + """Der Regelfall beim Umstieg: Admin traegt seine Adresse ein, meldet sich an.""" + eva = User( + username="eva", + password_hash=hash_password("geheim123"), + email="eva@example.com", + role=Role.admin, + ) + db.add(eva) + db.commit() + + gefunden = benutzer_aus_claims(db, provider, claims()) + + assert gefunden.id == eva.id + assert gefunden.role == Role.admin + assert db.query(User).count() == 1 + # Ab jetzt zaehlt die sub, nicht mehr die Adresse. + identitaet = db.query(UserIdentity).one() + assert (identitaet.provider, identitaet.subject) == ("authentik", "abc-123") + + +def test_unbestaetigte_email_verknuepft_nicht(db, provider): + """Sonst genuegt eine frei gewaehlte Adresse im IdP zur Kontouebernahme.""" + db.add( + User( + username="eva", + password_hash=hash_password("geheim123"), + email="eva@example.com", + role=Role.admin, + ) + ) + db.commit() + + gefunden = benutzer_aus_claims(db, provider, claims(email_verified=False)) + + assert gefunden.username != "eva" + assert gefunden.role == Role.user + assert db.query(User).count() == 2 + # Die Adresse gehoert weiterhin allein dem Bestandskonto. + assert gefunden.email is None + + +def test_ohne_auto_anlage_wird_abgewiesen(db, provider): + from dataclasses import replace + + ohne = replace(provider, auto_create=False) + + with pytest.raises(OidcFehler) as fehler: + benutzer_aus_claims(db, ohne, claims()) + + assert fehler.value.schluessel == "kein_konto" + assert db.query(User).count() == 0 + + +def test_auto_anlage_erzeugt_konto_ohne_passwort(db, provider): + neu = benutzer_aus_claims(db, provider, claims(preferred_username="eva.mueller")) + + assert neu.username == "eva.mueller" + assert neu.email == "eva@example.com" + # Rolle kommt NIE aus dem Anbieter. + assert neu.role == Role.user + # Reines SSO-Konto: kein Passwort, also auch kein Passwort-Login moeglich. + assert neu.password_hash is None + + +def test_namenskollision_uebernimmt_kein_fremdes_konto(db, provider): + """Ein passend gewaehlter preferred_username darf kein Konto kapern.""" + fremd = User(username="admin", password_hash=hash_password("geheim123"), role=Role.admin) + db.add(fremd) + db.commit() + + neu = benutzer_aus_claims(db, provider, claims(preferred_username="admin")) + + assert neu.id != fremd.id + assert neu.username == "admin-2" + assert neu.role == Role.user + # Das bestehende Admin-Konto bleibt unangetastet. + db.refresh(fremd) + assert fremd.role == Role.admin + assert fremd.password_hash is not None + + +def test_zweite_kennung_auf_verknuepftes_konto_wird_abgelehnt(db, provider): + eva = User(username="eva", password_hash=None, email="eva@example.com", role=Role.user) + db.add(eva) + db.flush() + db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="alt-999")) + db.commit() + + with pytest.raises(OidcFehler) as fehler: + benutzer_aus_claims(db, provider, claims(sub="neu-111")) + + assert fehler.value.schluessel == "konto_bereits_verknuepft" + + +def test_sso_konto_ist_per_passwort_nicht_anmeldbar(): + """Ohne Hash muss die Pruefung schlicht False sagen – kein AttributeError. + + Sonst antwortet POST /auth/login mit 500 statt mit einem ehrlichen 401. + """ + assert verify_password("irgendwas", None) is False + assert verify_password("irgendwas", "") is False + + +# ---- Merkzettel fuer den Browser-Weg --------------------------------------- + + +def test_zettel_gilt_genau_einmal(db): + """Ein zweiter Rueckweg mit demselben state muss ins Leere laufen.""" + zettel = oidc_state.anlegen(db, "authentik") + + erster = oidc_state.einloesen(db, zettel.state) + assert erster is not None + assert erster.provider == "authentik" + assert len(erster.code_verifier) >= 43 # RFC 7636 + + assert oidc_state.einloesen(db, zettel.state) is None + + +def test_abgelaufener_zettel_wird_abgelehnt(db): + zettel = oidc_state.anlegen(db, "authentik") + spaeter = datetime.now(timezone.utc) + oidc_state.GUELTIG + timedelta(seconds=1) + + assert oidc_state.einloesen(db, zettel.state, jetzt=spaeter) is None + + +def test_unbekannter_zettel_wird_abgelehnt(db): + assert oidc_state.einloesen(db, "nie-vergeben") is None + + +def test_aufraeumen_entfernt_nur_alte_zettel(db): + alt = oidc_state.anlegen(db, "authentik") + neu = oidc_state.anlegen(db, "authentik") + # Den einen kuenstlich altern lassen. + alt.created_at = datetime.now(timezone.utc) - oidc_state.GUELTIG - timedelta(minutes=1) + db.commit() + + assert oidc_state.aufraeumen(db) == 1 + assert oidc_state.einloesen(db, neu.state) is not None + + +def test_zettel_tragen_unterschiedliche_werte(db): + a = oidc_state.anlegen(db, "authentik") + b = oidc_state.anlegen(db, "authentik") + assert a.state != b.state + assert a.nonce != b.nonce + assert a.code_verifier != b.code_verifier + + +# ---- Passwort-Login gegen ein reines SSO-Konto ----------------------------- + + +def test_passwort_login_gegen_sso_konto_gibt_401(db): + """Kein 500: das Konto hat keinen Hash, das ist ein Anmeldefehler.""" + db.add(User(username="eva", password_hash=None, role=Role.user)) + db.commit() + + with pytest.raises(HTTPException) as fehler: + login(form=SimpleNamespace(username="eva", password="beliebig"), db=db) + + assert fehler.value.status_code == 401 + + +def test_sehr_langer_name_passt_in_die_spalte(db, provider): + """username ist String(64) – ein langer preferred_username darf nicht sprengen.""" + neu = benutzer_aus_claims(db, provider, claims(preferred_username="e" * 200)) + assert len(neu.username) <= 64 + + +# ---- Verknuepfung eines bestehenden Kontos --------------------------------- + + +def test_verknuepfung_haengt_kennung_an_das_angemeldete_konto(db, provider): + """Der Weg fuer Konten ohne (passende) E-Mail: Berechtigung aus der Sitzung.""" + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin) + db.add(eva) + db.commit() + + identitaet = identitaet_verknuepfen(db, eva, provider, claims(email=None)) + + assert identitaet.user_id == eva.id + assert identitaet.subject == "abc-123" + # Ab jetzt meldet dieselbe Kennung genau dieses Konto an – mit seiner Rolle. + gefunden = benutzer_aus_claims(db, provider, claims()) + assert gefunden.id == eva.id + assert gefunden.role == Role.admin + + +def test_verknuepfung_braucht_keine_bestaetigte_email(db, provider): + """Anders als die Erstverknuepfung: hier buergt die laufende Sitzung.""" + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin) + db.add(eva) + db.commit() + + identitaet_verknuepfen(db, eva, provider, claims(email_verified=False)) + + assert db.query(UserIdentity).count() == 1 + + +def test_verknuepfung_zweimal_ist_folgenlos(db, provider): + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user) + db.add(eva) + db.commit() + + erste = identitaet_verknuepfen(db, eva, provider, claims()) + zweite = identitaet_verknuepfen(db, eva, provider, claims()) + + assert erste.id == zweite.id + assert db.query(UserIdentity).count() == 1 + + +def test_fremde_kennung_wird_nicht_umgehaengt(db, provider): + """Auch eine gueltige Sitzung darf keine fremde Kennung an sich ziehen.""" + otto = User(username="otto", password_hash=hash_password("geheim123"), role=Role.user) + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin) + db.add_all([otto, eva]) + db.flush() + db.add(UserIdentity(user_id=otto.id, provider="authentik", subject="abc-123")) + db.commit() + + with pytest.raises(OidcFehler) as fehler: + identitaet_verknuepfen(db, eva, provider, claims()) + + assert fehler.value.schluessel == "kennung_fremd" + assert db.query(UserIdentity).one().user_id == otto.id + + +def test_zweite_kennung_desselben_dienstes_wird_abgelehnt(db, provider): + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user) + db.add(eva) + db.flush() + db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="alt-999")) + db.commit() + + with pytest.raises(OidcFehler) as fehler: + identitaet_verknuepfen(db, eva, provider, claims(sub="neu-111")) + + assert fehler.value.schluessel == "schon_verknuepft" + + +def test_loesen_entfernt_die_verknuepfung(db, provider): + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user) + db.add(eva) + db.flush() + db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123")) + db.commit() + + identitaet_loesen(db, eva, "authentik") + + assert db.query(UserIdentity).count() == 0 + + +def test_letzte_anmeldemoeglichkeit_bleibt_bestehen(db, provider): + """Ein reines SSO-Konto darf sich nicht selbst aussperren.""" + eva = User(username="eva", password_hash=None, role=Role.user) + db.add(eva) + db.flush() + db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123")) + db.commit() + + with pytest.raises(OidcFehler) as fehler: + identitaet_loesen(db, eva, "authentik") + + assert fehler.value.schluessel == "letzte_anmeldung" + assert db.query(UserIdentity).count() == 1 + + +def test_zettel_merkt_sich_das_zu_verknuepfende_konto(db): + """Nur so weiss der Rueckweg, dass er verknuepfen und nicht anmelden soll.""" + eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user) + db.add(eva) + db.commit() + + zettel = oidc_state.anlegen(db, "authentik", link_user_id=eva.id) + eingeloest = oidc_state.einloesen(db, zettel.state) + + assert eingeloest.link_user_id == eva.id + # Eine normale Anmeldung traegt das Feld nicht. + assert oidc_state.einloesen(db, oidc_state.anlegen(db, "authentik").state).link_user_id is None diff --git a/deploy/.env.example b/deploy/.env.example index 77cc6e9..4011736 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -18,3 +18,30 @@ ADMIN_PASSWORD=changeme # CORS (bei Betrieb hinter dem mitgelieferten nginx nicht nötig) CORS_ORIGINS=* + +# Oeffentliche Adresse dieser Instanz, z. B. https://vorrania.example.com +# Wird nur fuer Single Sign-on gebraucht (Rueckleit-Adresse) - sonst leer lassen. +PUBLIC_BASE_URL= + +# --------------------------------------------------------------------------- +# Single Sign-on (OpenID Connect), z. B. gegen Authentik. Leer = aus; dann +# bleibt es beim Passwort-Login und nichts aendert sich. +# +# Ein weiterer Anbieter ist reine Konfiguration: Kuerzel in OIDC_PROVIDERS +# ergaenzen (kommagetrennt) und einen zweiten Block mit dessen Praefix anlegen. +# --------------------------------------------------------------------------- +OIDC_PROVIDERS= +#OIDC_PROVIDERS=authentik +#OIDC_AUTHENTIK_DISPLAY_NAME=Authentik +#OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration +# Vertraulicher Client (Web-Oberflaeche) - mit Secret: +#OIDC_AUTHENTIK_CLIENT_ID= +#OIDC_AUTHENTIK_CLIENT_SECRET= +# Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE: +#OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios +# Neue Konten automatisch anlegen (Rolle "Benutzer"); false = nur bereits +# vorhandene Konten duerfen sich per SSO anmelden. +#OIDC_AUTHENTIK_AUTO_CREATE=true +# Nur bei abweichendem Reverse-Proxy-Pfad noetig. Ohne diese Zeile bildet der +# Server die Adresse als /api/auth/oidc/authentik/callback. +#OIDC_AUTHENTIK_REDIRECT_URI= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cac27f3..03a7a30 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -24,12 +24,22 @@ services: depends_on: db: condition: service_healthy + # Die Single-Sign-on-Bloecke heissen je Anbieter anders (OIDC__...). + # Einzeln aufzaehlen liesse sich das nur mit fest verdrahteten Kuerzeln – + # genau das soll ein weiterer Anbieter aber NICHT erfordern. Deshalb die + # ganze .env durchreichen; der environment-Block unten gewinnt bei + # Namensgleichheit und behaelt die zusammengesetzte DATABASE_URL. + env_file: + - .env environment: DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-vorrania}:${POSTGRES_PASSWORD:-vorrania}@db:5432/${POSTGRES_DB:-vorrania} JWT_SECRET: ${JWT_SECRET:-change-me} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} CORS_ORIGINS: ${CORS_ORIGINS:-*} + # Oeffentliche Adresse der Instanz – ohne sie kann der Server die + # Rueckleit-Adresse fuer Single Sign-on nicht bilden. + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} expose: - "8000" diff --git a/deploy/install.sh b/deploy/install.sh index fa992c9..02a6a06 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -133,6 +133,33 @@ ADMIN_PASSWORD=changeme # CORS (bei Betrieb hinter dem mitgelieferten nginx nicht nötig) CORS_ORIGINS=* + +# Oeffentliche Adresse dieser Instanz, z. B. https://vorrania.example.com +# Wird nur fuer Single Sign-on gebraucht (Rueckleit-Adresse) - sonst leer lassen. +PUBLIC_BASE_URL= + +# --------------------------------------------------------------------------- +# Single Sign-on (OpenID Connect), z. B. gegen Authentik. Leer = aus; dann +# bleibt es beim Passwort-Login und nichts aendert sich. +# +# Ein weiterer Anbieter ist reine Konfiguration: Kuerzel in OIDC_PROVIDERS +# ergaenzen (kommagetrennt) und einen zweiten Block mit dessen Praefix anlegen. +# --------------------------------------------------------------------------- +OIDC_PROVIDERS= +#OIDC_PROVIDERS=authentik +#OIDC_AUTHENTIK_DISPLAY_NAME=Authentik +#OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration +# Vertraulicher Client (Web-Oberflaeche) - mit Secret: +#OIDC_AUTHENTIK_CLIENT_ID= +#OIDC_AUTHENTIK_CLIENT_SECRET= +# Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE: +#OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios +# Neue Konten automatisch anlegen (Rolle "Benutzer"); false = nur bereits +# vorhandene Konten duerfen sich per SSO anmelden. +#OIDC_AUTHENTIK_AUTO_CREATE=true +# Nur bei abweichendem Reverse-Proxy-Pfad noetig. Ohne diese Zeile bildet der +# Server die Adresse als /api/auth/oidc/authentik/callback. +#OIDC_AUTHENTIK_REDIRECT_URI= EOF } @@ -143,6 +170,8 @@ env_hint() { echo " Vorlage/Beispiel: $EXAMPLE_FILE" warn "Dort kannst du bei Bedarf Werte anpassen: WEB_PORT (Port der Web-UI)," warn "ADMIN_USERNAME/ADMIN_PASSWORD sowie die Datenbank- und JWT-Secrets." + warn "Fuer Single Sign-on (z. B. Authentik): PUBLIC_BASE_URL und OIDC_* -" + warn "die Vorlage in $EXAMPLE_FILE zeigt einen vollstaendigen Beispielblock." warn "Nach Änderungen an der .env neu starten: $COMPOSE --env-file .env up -d" } @@ -179,6 +208,10 @@ JWT_SECRET=$(rand 48) ADMIN_USERNAME=$admin_user ADMIN_PASSWORD=$admin_pass CORS_ORIGINS=* + +# Fuer Single Sign-on ausfuellen (siehe .env.example). Leer = Passwort-Login. +PUBLIC_BASE_URL= +OIDC_PROVIDERS= EOF chmod 600 "$ENV_FILE" diff --git a/ios/README.md b/ios/README.md index 8448f3c..5977e54 100644 --- a/ios/README.md +++ b/ios/README.md @@ -43,6 +43,13 @@ Beim Start nach der Server-Adresse fragen lassen, z. B. `http://192.168.1.50:808 (dieselbe Adresse wie im Browser), dann mit deinem Benutzer anmelden. Adresse und Token bleiben gespeichert – das Token liegt im Keychain. +Hat der Server einen Anmeldedienst eingerichtet (Single Sign-on, siehe +[../README.md](../README.md#single-sign-on-optional)), erscheint darunter ein +Knopf „Mit … anmelden". Die Anmeldung läuft dann im System-Browser (AppAuth mit +PKCE, kein eingebettetes WebView); der Anmeldezustand samt Refresh-Token liegt +je Serverprofil getrennt im Keychain, und die App erneuert ein abgelaufenes +Token still im Hintergrund. + ## Home-Screen-Shortcuts Es gibt zwei Wege direkt in den Scan-Bildschirm: diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 3502e3c..8ec11a1 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -37,9 +37,63 @@ actor APIClient { } private func send(_ request: URLRequest, as type: T.Type) async throws -> T { - let (data, response) = try await URLSession.shared.data(for: request) - try check(response, data: data) - return try JSONDecoder().decode(T.self, from: data) + let daten = try await sendeMitErneuerung(request) + return try JSONDecoder().decode(T.self, from: daten) + } + + /// Fuehrt den Aufruf aus und wiederholt ihn EINMAL, wenn das Vorrania-Token + /// abgelaufen ist und sich die Anmeldung still erneuern laesst. + /// + /// Betrifft nur Anmeldungen ueber einen Anmeldedienst: dort liegt ein + /// Refresh-Token auf dem Geraet. Nach einer Passwort-Anmeldung gibt es + /// nichts zu erneuern, dann bleibt es beim 401 wie bisher. + private func sendeMitErneuerung(_ request: URLRequest) async throws -> Data { + let (daten, antwort) = try await URLSession.shared.data(for: request) + do { + try check(antwort, data: daten) + return daten + } catch APIError.unauthorized { + guard await erneuereAnmeldung() else { throw APIError.unauthorized } + var wiederholung = request + if let token { + wiederholung.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (daten2, antwort2) = try await URLSession.shared.data(for: wiederholung) + try check(antwort2, data: daten2) + return daten2 + } + } + + /// Laufende Erneuerung, damit zehn gleichzeitig abgewiesene Aufrufe nicht + /// zehn Erneuerungen ausloesen. Der Actor serialisiert bis zum ersten await, + /// wer hier ankommt, haengt sich also an eine bereits laufende an. + private var laufendeErneuerung: Task? + + private func erneuereAnmeldung() async -> Bool { + if let laufend = laufendeErneuerung { return await laufend.value } + let aufgabe = Task { await Self.erneuereJetzt() } + laufendeErneuerung = aufgabe + defer { laufendeErneuerung = nil } + return await aufgabe.value + } + + private static func erneuereJetzt() async -> Bool { + let sitzung = Session.shared + guard let profil = sitzung.activeProfile, + let slug = profil.oidcSlug, !slug.isEmpty else { return false } + do { + let idToken = try await OIDCLogin.shared.frischesIdToken(fuer: profil.id) + let token = try await APIClient.shared.oidcNativeLogin(slug: slug, idToken: idToken) + await MainActor.run { + sitzung.store(token: token.accessToken, username: token.username, + isAdmin: token.role == "admin", persist: sitzung.stayLoggedIn, + oidcSlug: slug) + } + return true + } catch { + // Refresh-Token abgelaufen oder zurueckgezogen: normale Neuanmeldung. + return false + } } private func check(_ response: URLResponse, data: Data) throws { @@ -77,6 +131,27 @@ actor APIClient { try await send(try makeRequest("/auth/me"), as: MeResponse.self) } + /// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login. + func oidcProviders() async throws -> [OidcProviderResponse] { + let (daten, antwort) = try await URLSession.shared.data( + for: try makeRequest("/auth/oidc/providers") + ) + try check(antwort, data: daten) + return try JSONDecoder().decode([OidcProviderResponse].self, from: daten) + } + + /// Tauscht das ID-Token des Anmeldedienstes gegen ein Vorrania-Token. + /// + /// Bewusst ohne die Erneuerungsschicht: dieser Aufruf IST die Erneuerung, + /// ein 401 hier muss zur normalen Anmeldung fuehren. + func oidcNativeLogin(slug: String, idToken: String, nonce: String? = nil) async throws -> TokenResponse { + var request = try makeRequest("/auth/oidc/\(slug)/native", method: "POST") + try jsonBody(&request, OidcNativeLoginBody(idToken: idToken, nonce: nonce)) + let (daten, antwort) = try await URLSession.shared.data(for: request) + try check(antwort, data: daten) + return try JSONDecoder().decode(TokenResponse.self, from: daten) + } + // MARK: - Stammdaten func units() async throws -> [Unit] { @@ -142,8 +217,12 @@ actor APIClient { let request = try makeRequest("/products/\(id)/image" + (thumb ? "?thumb=1" : "")) let (data, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil } - try check(response, data: data) - return data + do { + try check(response, data: data) + return data + } catch APIError.unauthorized { + return try await sendeMitErneuerung(request) + } } /// Eigenes Foto (Kamera/Galerie) als multipart hochladen. @@ -330,8 +409,7 @@ actor APIClient { /// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren. private func sendNoContent(_ request: URLRequest) async throws { - let (data, response) = try await URLSession.shared.data(for: request) - try check(response, data: data) + _ = try await sendeMitErneuerung(request) } // MARK: - Gegenstaende (Non-Food) diff --git a/ios/Sources/LoginView.swift b/ios/Sources/LoginView.swift index 2446460..4435dd0 100644 --- a/ios/Sources/LoginView.swift +++ b/ios/Sources/LoginView.swift @@ -10,6 +10,8 @@ struct LoginView: View { @State private var busy = false @State private var stayLoggedIn = Session.shared.stayLoggedIn @State private var manageShown = false + /// Am eingetragenen Server eingerichtete Anmeldedienste. Leer = nur Passwort. + @State private var anbieter: [OidcProviderResponse] = [] var body: some View { NavigationStack { @@ -19,6 +21,12 @@ struct LoginView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) + // Erst mit hinterlegter Adresse laesst sich der Server + // nach seinen Anmeldediensten fragen. + .onSubmit { + session.setServer(server) + Task { await anbieterLaden() } + } // Mit mehreren Servern ist das hier der Weg zurueck, wenn man // am falschen gelandet ist. if session.profiles.count > 1 { @@ -49,6 +57,18 @@ struct LoginView: View { Button(busy ? "Anmelden…" : "Anmelden") { Task { await login() } } .disabled(busy || server.isEmpty || username.isEmpty) } + // Nur wenn der Server einen App-Zugang eingerichtet hat. Ohne + // Anmeldedienst sieht die Maske genauso aus wie bisher. + if !anbieter.isEmpty { + Section("Single Sign-on") { + ForEach(anbieter) { dienst in + Button("Mit \(dienst.displayName) anmelden") { + Task { await ssoLogin(dienst) } + } + .disabled(busy || dienst.nativeClientID == nil) + } + } + } } .navigationTitle(session.activeProfile?.name ?? "Vorrania") .toolbar { @@ -60,7 +80,10 @@ struct LoginView: View { } // Beim Start und bei jedem Serverwechsel die Felder auf das aktive // Profil setzen - sonst steht die Adresse des vorigen Servers da. - .task(id: session.activeProfileID) { syncFromProfile() } + .task(id: session.activeProfileID) { + syncFromProfile() + await anbieterLaden() + } } private var profileSelection: Binding { @@ -77,6 +100,36 @@ struct LoginView: View { error = nil } + /// Fragt den Server, welche Anmeldedienste er kennt. Der APIClient liest die + /// Adresse aus der Sitzung – die Abfrage geht also erst, wenn ein Profil da + /// ist. Ein Fehler bleibt still: der Passwort-Login muss auch dann gehen, + /// wenn der Server alt ist und den Endpunkt gar nicht kennt. + private func anbieterLaden() async { + guard session.activeProfile != nil else { anbieter = []; return } + anbieter = (try? await APIClient.shared.oidcProviders()) ?? [] + } + + private func ssoLogin(_ dienst: OidcProviderResponse) async { + error = nil + busy = true + defer { busy = false } + session.setServer(server) + guard let profilID = session.activeProfileID else { return } + do { + let (idToken, nonce) = try await OIDCLogin.shared.anmelden( + provider: dienst, profileID: profilID + ) + let token = try await APIClient.shared.oidcNativeLogin( + slug: dienst.slug, idToken: idToken, nonce: nonce + ) + session.store(token: token.accessToken, username: token.username, + isAdmin: token.role == "admin", persist: stayLoggedIn, + oidcSlug: dienst.slug) + } catch { + self.error = error.localizedDescription + } + } + private func login() async { error = nil busy = true diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index f75dbe9..414f1d0 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -13,6 +13,48 @@ struct TokenResponse: Codable { } } +/// Ein am Server eingerichteter Anmeldedienst (Single Sign-on). +/// +/// Kommt vom jeweiligen Server, nicht aus der App: So laesst sich ein weiterer +/// Anbieter allein durch Serverkonfiguration ergaenzen, und bei mehreren +/// Serverprofilen kann jedes einen eigenen haben. +struct OidcProviderResponse: Codable, Identifiable { + let slug: String + let displayName: String + let discoveryURL: String + /// Client-ID des oeffentlichen App-Clients. Fehlt sie, ist fuer diesen + /// Anbieter nur die Anmeldung im Webbrowser eingerichtet. + let nativeClientID: String? + let nativeRedirectURI: String + let scopes: String + + var id: String { slug } + var scopeListe: [String] { scopes.split(separator: " ").map(String.init) } + + enum CodingKeys: String, CodingKey { + case slug + case displayName = "display_name" + case discoveryURL = "discovery_url" + case nativeClientID = "native_client_id" + case nativeRedirectURI = "native_redirect_uri" + case scopes + } +} + +/// Anfragekoerper fuer POST /auth/oidc//native. +struct OidcNativeLoginBody: Encodable { + let idToken: String + /// Die in der Anmeldeanfrage verwendete nonce. Nur beim ersten Anmelden + /// vorhanden – ein per Refresh geholtes ID-Token traegt sie nicht + /// zwangslaeufig, deshalb optional. + let nonce: String? + + enum CodingKeys: String, CodingKey { + case idToken = "id_token" + case nonce + } +} + struct MeResponse: Codable { let id: Int let username: String diff --git a/ios/Sources/OIDCLogin.swift b/ios/Sources/OIDCLogin.swift new file mode 100644 index 0000000..2865b3f --- /dev/null +++ b/ios/Sources/OIDCLogin.swift @@ -0,0 +1,222 @@ +import AppAuth +import Foundation +import UIKit + +/// Anmeldung ueber einen OpenID-Connect-Dienst (z. B. Authentik). +/// +/// Der gesamte Vorgang laeuft in AppAuth und damit im **System-Browser** +/// (`ASWebAuthenticationSession`) – nie in einem eingebetteten WebView. Das ist +/// keine Geschmacksfrage: nur so sieht der Nutzer die echte Adresszeile, und die +/// App bekommt sein Passwort nie zu Gesicht. +/// +/// Die App ist beim Anmeldedienst ein **oeffentlicher** Client: kein +/// Client-Secret, dafuer PKCE (das setzt AppAuth bei diesem Aufbau von selbst). +/// +/// Der Anmeldezustand (inkl. Refresh-Token) liegt je Serverprofil im Keychain. +@MainActor +final class OIDCLogin { + static let shared = OIDCLogin() + + /// Muss gehalten werden, solange der Browser offen ist – sonst kann der + /// Rueckweg nicht mehr zugestellt werden. + private var laufenderFlow: OIDExternalUserAgentSession? + + /// Laufende Erneuerungen je Profil. Ohne das loesen mehrere gleichzeitige + /// 401er ebenso viele parallele Erneuerungen aus – und alle bis auf eine + /// scheitern, weil Authentik ein Refresh-Token nur einmal einloest. + private var laufendeErneuerung: [UUID: Task] = [:] + + private init() {} + + nonisolated static func keychainAccount(for id: UUID) -> String { + "vorrania-oidc-\(id.uuidString)" + } + + // MARK: - Anmelden + + /// Fuehrt den Anmeldevorgang durch und liefert das ID-Token, das der Server + /// gegen ein Vorrania-Token tauscht. + func anmelden( + provider: OidcProviderResponse, profileID: UUID + ) async throws -> (idToken: String, nonce: String?) { + guard let clientID = provider.nativeClientID, !clientID.isEmpty else { + throw OIDCFehler.keinAppClient + } + guard let discoveryURL = URL(string: provider.discoveryURL), + let redirectURL = URL(string: provider.nativeRedirectURI) else { + throw OIDCFehler.konfigurationUnbrauchbar + } + guard let presenting = Self.oberstesFenster() else { + throw OIDCFehler.keinFenster + } + + let konfiguration = try await Self.konfiguration(von: discoveryURL) + + let anfrage = OIDAuthorizationRequest( + configuration: konfiguration, + clientId: clientID, + // Ausdruecklich nil: ein oeffentlicher Client hat kein Secret und darf + // auch nie eines mitschicken. + clientSecret: nil, + // offline_access ist Pflicht – ohne diesen Scope liefert Authentik + // seit 2024.2 kein Refresh-Token, und die App muesste sich staendig + // neu anmelden. + scopes: provider.scopeListe, + redirectURL: redirectURL, + responseType: OIDResponseTypeCode, + additionalParameters: nil + ) + + let zustand: OIDAuthState = try await withCheckedThrowingContinuation { fortsetzung in + laufenderFlow = OIDAuthState.authState( + byPresenting: anfrage, + presenting: presenting + ) { zustand, fehler in + if let zustand { + fortsetzung.resume(returning: zustand) + } else { + fortsetzung.resume(throwing: fehler ?? OIDCFehler.abgebrochen) + } + } + } + laufenderFlow = nil + + sichern(zustand, profileID: profileID) + guard let idToken = zustand.lastTokenResponse?.idToken else { + throw OIDCFehler.keinIdToken + } + // AppAuth erzeugt die nonce selbst; der Server prueft sie damit erneut. + return (idToken, zustand.lastAuthorizationResponse.request.nonce) + } + + /// Fuehrt den Rueckweg aus dem Browser dem laufenden Vorgang zu. + /// Bei `ASWebAuthenticationSession` faengt die Sitzung den Aufruf selbst ab; + /// dies ist der Rueckfall, falls die Adresse doch ueber `onOpenURL` ankommt. + @discardableResult + func rueckwegVerarbeiten(_ url: URL) -> Bool { + guard let flow = laufenderFlow else { return false } + if flow.resumeExternalUserAgentFlow(with: url) { + laufenderFlow = nil + return true + } + return false + } + + // MARK: - Erneuern + + /// Holt ein frisches ID-Token, notfalls ueber das Refresh-Token. + /// + /// Ohne nonce: ein per Refresh ausgestelltes ID-Token muss sie laut + /// OIDC-Spezifikation nur uebernehmen, *wenn* es sie mitfuehrt – viele + /// Anbieter lassen sie weg. Signatur, iss, aud und exp prueft der Server + /// unveraendert. + /// + /// Mehrere gleichzeitige Aufrufe teilen sich denselben Vorgang: das + /// Refresh-Token laesst sich nur einmal einloesen. + func frischesIdToken(fuer profileID: UUID) async throws -> String { + if let laufend = laufendeErneuerung[profileID] { + return try await laufend.value + } + let aufgabe = Task { [weak self] in + guard let self, let zustand = self.geladenerZustand(profileID: profileID) else { + throw OIDCFehler.nichtAngemeldet + } + let idToken: String = try await withCheckedThrowingContinuation { fortsetzung in + zustand.performAction { _, idToken, fehler in + if let idToken { + fortsetzung.resume(returning: idToken) + } else { + fortsetzung.resume(throwing: fehler ?? OIDCFehler.erneuerungGescheitert) + } + } + } + // performAction kann den Zustand veraendert haben (neues Refresh-Token). + self.sichern(zustand, profileID: profileID) + return idToken + } + laufendeErneuerung[profileID] = aufgabe + defer { laufendeErneuerung[profileID] = nil } + return try await aufgabe.value + } + + func istAngemeldet(profileID: UUID) -> Bool { + geladenerZustand(profileID: profileID) != nil + } + + // MARK: - Ablage + + private func sichern(_ zustand: OIDAuthState, profileID: UUID) { + guard let daten = try? NSKeyedArchiver.archivedData( + withRootObject: zustand, requiringSecureCoding: true + ) else { return } + // Enthaelt das Refresh-Token – gehoert deshalb in den Keychain und nicht + // in die UserDefaults. + Keychain.writeData(daten, account: Self.keychainAccount(for: profileID)) + } + + private func geladenerZustand(profileID: UUID) -> OIDAuthState? { + guard let daten = Keychain.readData(account: Self.keychainAccount(for: profileID)) else { + return nil + } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: daten) + } + + nonisolated static func abmelden(profileID: UUID) { + Keychain.delete(account: keychainAccount(for: profileID)) + } + + // MARK: - Hilfen + + private static func konfiguration(von url: URL) async throws -> OIDServiceConfiguration { + try await withCheckedThrowingContinuation { fortsetzung in + OIDAuthorizationService.discoverConfiguration(forDiscoveryURL: url) { konfig, fehler in + if let konfig { + fortsetzung.resume(returning: konfig) + } else { + fortsetzung.resume(throwing: fehler ?? OIDCFehler.discoveryGescheitert) + } + } + } + } + + private static func oberstesFenster() -> UIViewController? { + let szene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + guard var oben = szene?.keyWindow?.rootViewController else { return nil } + while let naechst = oben.presentedViewController { oben = naechst } + return oben + } +} + +enum OIDCFehler: LocalizedError { + case keinAppClient + case konfigurationUnbrauchbar + case keinFenster + case discoveryGescheitert + case keinIdToken + case abgebrochen + case nichtAngemeldet + case erneuerungGescheitert + + var errorDescription: String? { + switch self { + case .keinAppClient: + return "Für diesen Anmeldedienst ist kein App-Zugang eingerichtet." + case .konfigurationUnbrauchbar: + return "Der Server hat eine unbrauchbare Anmelde-Konfiguration geliefert." + case .keinFenster: + return "Die Anmeldung konnte nicht angezeigt werden." + case .discoveryGescheitert: + return "Der Anmeldedienst ist nicht erreichbar." + case .keinIdToken: + return "Der Anmeldedienst hat kein ID-Token geliefert." + case .abgebrochen: + return "Die Anmeldung wurde abgebrochen." + case .nichtAngemeldet: + return "Für diesen Server liegt keine Anmeldung beim Anmeldedienst vor." + case .erneuerungGescheitert: + return "Die Anmeldung konnte nicht erneuert werden. Bitte neu anmelden." + } + } +} diff --git a/ios/Sources/ServerProfiles.swift b/ios/Sources/ServerProfiles.swift index 6948430..45f24e6 100644 --- a/ios/Sources/ServerProfiles.swift +++ b/ios/Sources/ServerProfiles.swift @@ -17,14 +17,22 @@ struct ServerProfile: Codable, Identifiable, Equatable { /// Zuletzt bekannte Rolle. Entscheidet, ob der Verwaltungs-Tab erscheint, /// bevor `me()` den Wert frisch bestaetigt hat. var isAdmin: Bool + /// Kuerzel des Anmeldedienstes, ueber den man sich hier angemeldet hat + /// (z. B. "authentik"); leer bei Passwort-Anmeldung. Nur damit weiss die App + /// spaeter, an welchem Endpunkt sie das abgelaufene Token erneuern muss. + /// + /// Optional deklariert, damit bereits gespeicherte Profile weiterhin gelesen + /// werden koennen – der Decoder liefert fuer den fehlenden Schluessel nil. + var oidcSlug: String? init(id: UUID = UUID(), name: String, urlString: String, - username: String = "", isAdmin: Bool = false) { + username: String = "", isAdmin: Bool = false, oidcSlug: String? = nil) { self.id = id self.name = name self.urlString = urlString self.username = username self.isAdmin = isAdmin + self.oidcSlug = oidcSlug } var url: URL? { Session.normalize(urlString) } diff --git a/ios/Sources/Session.swift b/ios/Sources/Session.swift index 7a087de..14fac10 100644 --- a/ios/Sources/Session.swift +++ b/ios/Sources/Session.swift @@ -122,6 +122,7 @@ final class Session: ObservableObject { /// wenn der Server aus der Liste verschwindet. func removeProfile(id: UUID) { Keychain.delete(account: Session.keychainAccount(for: id)) + OIDCLogin.abmelden(profileID: id) NotificationStore.remove(for: id) AppDashboardStore.remove(for: id) profiles.removeAll { $0.id == id } @@ -166,7 +167,11 @@ final class Session: ObservableObject { /// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft. /// Auf dem Geraet bleibt dann nichts zurueck. - func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) { + /// `oidcSlug` merkt, ueber welchen Anmeldedienst die Anmeldung lief – nur so + /// weiss die App spaeter, wo sie ein abgelaufenes Token erneuern kann. Bei + /// Passwort-Anmeldung bleibt es leer. + func store(token newToken: String, username name: String, isAdmin admin: Bool, + persist: Bool, oidcSlug: String? = nil) { token = newToken stayLoggedIn = persist UserDefaults.standard.set(persist, forKey: stayKey) @@ -175,6 +180,7 @@ final class Session: ObservableObject { let index = profiles.firstIndex(where: { $0.id == id }) else { return } profiles[index].isAdmin = admin profiles[index].username = persist ? name : "" + profiles[index].oidcSlug = oidcSlug ProfileStore.save(profiles) if persist { @@ -189,9 +195,13 @@ final class Session: ObservableObject { token = nil guard let id = activeProfileID else { return } Keychain.delete(account: Session.keychainAccount(for: id)) + // Der Anmeldezustand beim Dienst enthaelt das Refresh-Token. Bliebe er + // liegen, waere man nach dem Abmelden faktisch weiter angemeldet. + OIDCLogin.abmelden(profileID: id) if let index = profiles.firstIndex(where: { $0.id == id }) { profiles[index].username = "" profiles[index].isAdmin = false + profiles[index].oidcSlug = nil ProfileStore.save(profiles) } } @@ -221,18 +231,29 @@ enum Keychain { private static let service = "com.scarriffle.vorrania" static func write(_ value: String, account: String) { + writeData(Data(value.utf8), account: account) + } + + static func read(account: String) -> String? { + guard let data = readData(account: account) else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Der AppAuth-Anmeldezustand ist ein Archiv und kein Text – deshalb neben + /// den String-Varianten auch der direkte Weg ueber `Data`. + static func writeData(_ value: Data, account: String) { delete(account: account) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, - kSecValueData as String: Data(value.utf8), + kSecValueData as String: value, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, ] SecItemAdd(query as CFDictionary, nil) } - static func read(account: String) -> String? { + static func readData(account: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -243,7 +264,7 @@ enum Keychain { var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) + return data } static func delete(account: String) { diff --git a/ios/Sources/VorraniaApp.swift b/ios/Sources/VorraniaApp.swift index 7608b21..f3495a2 100644 --- a/ios/Sources/VorraniaApp.swift +++ b/ios/Sources/VorraniaApp.swift @@ -75,7 +75,14 @@ struct VorraniaApp: App { .environmentObject(session) .environmentObject(router) .environmentObject(display) - .onOpenURL { router.handle(url: $0) } + .onOpenURL { url in + // Rueckweg einer laufenden SSO-Anmeldung zuerst anbieten. + // ASWebAuthenticationSession faengt ihn normalerweise selbst + // ab; kommt er doch hier an, wuerde ihn der Router still + // schlucken (vorrania://oauth/... passt auf keine Route). + if OIDCLogin.shared.rueckwegVerarbeiten(url) { return } + router.handle(url: url) + } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in if let url = activity.webpageURL { router.handle(url: url) } } diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index b68893f..4a186ea 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; }; 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; }; 56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */; }; + 56C15BE80330E00D82AF3BD1 /* OIDCLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */; }; 5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; }; 6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; }; 728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; }; @@ -40,6 +41,7 @@ D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; }; D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; }; DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; }; + E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 31DDA98DEF7F8937A8719C80 /* AppAuth */; }; E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; }; ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; }; F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; }; @@ -76,6 +78,7 @@ 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = ""; }; 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = ""; }; + 9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCLogin.swift; sourceTree = ""; }; A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = ""; }; A75926511854BB8EE316ED3A /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = ""; }; @@ -90,6 +93,17 @@ EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 6ABD1700B66B20DDD542B777 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 5216EA3D3DA41052FD0202A7 /* Sources */ = { isa = PBXGroup; @@ -121,6 +135,7 @@ 3731608B8D48960DC98912BC /* NotificationSettings.swift */, 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */, + 9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */, @@ -162,6 +177,7 @@ buildPhases = ( 08E03A34841D4B2D2C7E2E81 /* Sources */, 049B3FB4A4D974E54814E40C /* Resources */, + 6ABD1700B66B20DDD542B777 /* Frameworks */, ); buildRules = ( ); @@ -169,6 +185,7 @@ ); name = Vorrania; packageProductDependencies = ( + 31DDA98DEF7F8937A8719C80 /* AppAuth */, ); productName = Vorrania; productReference = 4BF4E4F5524B15728CEEE116 /* Vorrania.app */; @@ -198,6 +215,9 @@ ); mainGroup = 5612AB3DD4CD09397A8F1BAB; minimizedProjectReferenceProxies = 1; + packageReferences = ( + AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 9BD53E938DBCB7F0CF657753 /* Products */; projectDirPath = ""; @@ -250,6 +270,7 @@ 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */, 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */, 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, + 56C15BE80330E00D82AF3BD1 /* OIDCLogin.swift in Sources */, D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, 4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */, @@ -452,6 +473,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openid/AppAuth-iOS"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.6; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 31DDA98DEF7F8937A8719C80 /* AppAuth */ = { + isa = XCSwiftPackageProductDependency; + package = AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; + productName = AppAuth; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = B35524A6F4D28034E6687EE2 /* Project object */; } diff --git a/ios/Vorrania.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Vorrania.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..c543d0e --- /dev/null +++ b/ios/Vorrania.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c2c3123823fbf9ecb5ff108c887e3a41cb72f13d86620f12b66cac13738096c1", + "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS", + "state" : { + "revision" : "2781038865a80e2c425a1da12cc1327bcd56501f", + "version" : "1.7.6" + } + } + ], + "version" : 3 +} diff --git a/ios/project.yml b/ios/project.yml index 9a3d45a..2c07bcd 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -12,6 +12,14 @@ options: iOS: "16.0" createIntermediateGroups: true +# Erste (und einzige) Fremdabhaengigkeit: AppAuth uebernimmt den +# OpenID-Connect-Anmeldevorgang. Selbst gebaut waere das ein Sicherheitsrisiko – +# PKCE, State-Pruefung und der Ablauf im System-Browser stecken hier drin. +packages: + AppAuth: + url: https://github.com/openid/AppAuth-iOS + from: 1.7.6 + targets: Vorrania: type: application @@ -26,6 +34,9 @@ targets: - Info.plist # App-Icon im Icon-Composer-Format (Xcode 26). - path: AppIcon.icon + dependencies: + - package: AppAuth + product: AppAuth settings: base: INFOPLIST_FILE: Sources/Info.plist diff --git a/web/src/App.jsx b/web/src/App.jsx index 6b99398..a59ee46 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -6,6 +6,7 @@ import Icon from "./components/Icon"; import ErrorBoundary from "./components/ErrorBoundary"; import BrandMark from "./components/BrandMark"; import Login from "./pages/Login"; +import OidcCallback from "./pages/OidcCallback"; import Dashboard from "./pages/Dashboard"; import Products from "./pages/Products"; import ItemList from "./pages/ItemList"; @@ -27,6 +28,7 @@ import ShoppingList from "./pages/ShoppingList"; import History from "./pages/History"; import Transfer from "./pages/Transfer"; import Settings from "./pages/Settings"; +import Konto from "./pages/Konto"; const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link"); @@ -104,10 +106,11 @@ function Sidebar() { )}
-
+ {/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */} + {user?.username} {isAdmin ? "Administrator" : "Benutzer"} -
+ @@ -187,6 +190,8 @@ export default function App() { } /> } /> } /> + {/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */} + } /> } /> } /> } /> @@ -194,6 +199,10 @@ export default function App() { } /> } /> } /> + {/* Rueckweg vom Single-Sign-on: MUSS oeffentlich sein (der Nutzer ist in + diesem Moment noch nicht angemeldet) und MUSS vor dem Auffangmuster + unten stehen – sonst schluckt "*" die Weiterleitung samt Token. */} + } /> } /> ); diff --git a/web/src/api.js b/web/src/api.js index f1a9c95..bdb78ac 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -18,6 +18,10 @@ export function setToken(token) { // Wird bei einer abgelaufenen/ungueltigen Anmeldung gerufen (HTTP 401). // Ohne das bleibt die Oberflaeche „angemeldet", waehrend jede Abfrage scheitert – // man sieht dann leere Seiten statt eines Hinweises. +// Pfade, bei denen ein 401 NICHT „Sitzung abgelaufen" heisst: beim Anmelden +// selbst heisst er „Passwort falsch", und die Anbieterliste ist oeffentlich. +const OHNE_ABMELDUNG = ["/auth/login", "/auth/oidc/providers"]; + let beiAbmeldung = null; export function setUnauthorizedHandler(fn) { beiAbmeldung = fn; @@ -51,7 +55,7 @@ async function request(path, { method = "GET", body, form, formData } = {}) { // Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite. // Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch". - if (resp.status === 401 && path !== "/auth/login") { + if (resp.status === 401 && !OHNE_ABMELDUNG.includes(path)) { beiAbmeldung?.(); } @@ -146,6 +150,16 @@ export const api = { login: (username, password) => request("/auth/login", { method: "POST", form: { username, password } }), me: () => request("/auth/me"), + // Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die + // Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer + // und es taucht kein SSO-Knopf auf. + oidcProviders: () => request("/auth/oidc/providers"), + // Mit welchen Anmeldediensten das eigene Konto verknuepft ist. + oidcIdentities: () => request("/auth/oidc/identities"), + // Startet die Verknuepfung. Muss POST sein, damit das Bearer-Token der + // laufenden Sitzung mitgeht - bei einer Seitennavigation ginge das nicht. + oidcLinkStart: (slug) => request(`/auth/oidc/${slug}/link`, { method: "POST" }), + oidcUnlink: (slug) => request(`/auth/oidc/${slug}/link`, { method: "DELETE" }), // Produkte listProducts: (q, categoryId) => { diff --git a/web/src/auth.jsx b/web/src/auth.jsx index 2990637..a77cca9 100644 --- a/web/src/auth.jsx +++ b/web/src/auth.jsx @@ -63,6 +63,26 @@ export function AuthProvider({ children }) { return me; } + // Nach dem Single-Sign-on kommt das fertige Token von aussen zurueck (aus dem + // Adressfragment), nicht aus einem Formular. `login` passt dafuer nicht – das + // kennt nur Benutzername und Passwort. + async function tokenUebernehmen(token) { + setToken(token); + try { + const me = await api.me(); + setUser(me); + setAbgelaufen(false); + setStartFehler(null); + return me; + } catch (err) { + // Ein Token, mit dem sich nichts abrufen laesst, darf nicht liegenbleiben – + // sonst zeigt die Oberflaeche „angemeldet" und lauter leere Seiten. + setToken(null); + setUser(null); + throw err; + } + } + function logout() { setToken(null); setUser(null); @@ -78,7 +98,7 @@ export function AuthProvider({ children }) { return ( {children} diff --git a/web/src/pages/Konto.jsx b/web/src/pages/Konto.jsx new file mode 100644 index 0000000..dbdfaf2 --- /dev/null +++ b/web/src/pages/Konto.jsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { useAuth } from "../auth"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; +import Icon from "../components/Icon"; + +// Kennungen aus dem Rueckweg des Anmeldedienstes (`/konto#sso_error=…`). +const SSO_FEHLERTEXTE = { + abgebrochen: "Die Verknüpfung wurde abgebrochen.", + sitzung_abgelaufen: "Der Vorgang hat zu lange gedauert. Bitte noch einmal versuchen.", + state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.", + unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.", + anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.", + kennung_fremd: + "Diese Anmeldung gehört bereits zu einem anderen Vorrania-Konto. " + + "Melde dich dort ab oder verwende eine andere Kennung.", + schon_verknuepft: "Dieses Konto ist mit dem Anmeldedienst bereits verknüpft.", + konto_verschwunden: "Das Konto gibt es nicht mehr.", +}; + +function datum(wert) { + if (!wert) return "–"; + return new Date(wert).toLocaleDateString("de-CH", { + day: "2-digit", month: "2-digit", year: "numeric", + }); +} + +/** + * „Mein Konto": zeigt, wie man sich an diesem Server anmelden kann, und + * verknüpft das Konto mit einem Anmeldedienst (Single Sign-on). + * + * Der Weg über diese Seite ist der einzige, der **ohne** hinterlegte + * E-Mail-Adresse funktioniert: Die Berechtigung stammt aus der laufenden + * Sitzung, nicht aus einem Claim des Anmeldedienstes. + */ +export default function Konto() { + const { user } = useAuth(); + const confirm = useConfirm(); + const toast = useToast(); + const [anbieter, setAnbieter] = useState([]); + const [verknuepfungen, setVerknuepfungen] = useState([]); + const [fehler, setFehler] = useState(null); + const [laedt, setLaedt] = useState(true); + const [busy, setBusy] = useState(null); + + async function laden() { + try { + const [liste, meine] = await Promise.all([ + api.oidcProviders().catch(() => []), + api.oidcIdentities(), + ]); + setAnbieter(liste || []); + setVerknuepfungen(meine || []); + } catch (err) { + setFehler(err.message); + } finally { + setLaedt(false); + } + } + + useEffect(() => { laden(); }, []); + + // Rueckmeldung aus dem Rueckweg einsammeln und die Adresszeile saeubern. + useEffect(() => { + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, "")); + const verknuepft = fragment.get("sso_linked"); + const kennung = fragment.get("sso_error"); + if (!verknuepft && !kennung) return; + if (verknuepft) toast("Konto verknüpft."); + if (kennung) { + setFehler(SSO_FEHLERTEXTE[kennung] || "Die Verknüpfung ist fehlgeschlagen."); + } + window.history.replaceState(null, "", window.location.pathname); + }, [toast]); + + async function verknuepfen(slug) { + setFehler(null); + setBusy(slug); + try { + // Erst die Adresse holen (mit Anmeldung im Header), dann dorthin + // navigieren – ein einfacher Link könnte das Token nicht mitschicken. + const { authorization_url } = await api.oidcLinkStart(slug); + window.location.assign(authorization_url); + } catch (err) { + setFehler(err.message); + setBusy(null); + } + } + + async function loesen(slug, name) { + const ok = await confirm({ + title: `Verknüpfung mit „${name}" lösen?`, + message: "Danach ist die Anmeldung über diesen Dienst nicht mehr möglich.", + confirmLabel: "Lösen", + danger: true, + }); + if (!ok) return; + setFehler(null); + setBusy(slug); + try { + await api.oidcUnlink(slug); + toast("Verknüpfung gelöst."); + await laden(); + } catch (err) { + setFehler(err.message); + } finally { + setBusy(null); + } + } + + const verknuepftMit = (slug) => verknuepfungen.find((v) => v.provider === slug); + // Verknüpfungen zu Diensten, die es am Server nicht mehr gibt – lösen können + // muss man sie trotzdem, sonst hängen sie unsichtbar am Konto. + const verwaiste = verknuepfungen.filter( + (v) => !anbieter.some((a) => a.slug === v.provider) + ); + + return ( +
+
+
+

Mein Konto

+
Wie du dich an diesem Server anmeldest
+
+
+ + {fehler &&
{fehler}
} + +
+

Konto

+
+
Benutzername
{user?.username}
+
E-Mail
+
{user?.email || nicht hinterlegt}
+
Rolle
+
{user?.role === "admin" ? "Administrator" : "Benutzer"}
+
+
+ +
+

Single Sign-on

+ + {laedt ? ( +
Lädt…
+ ) : anbieter.length === 0 && verwaiste.length === 0 ? ( +
+ Auf diesem Server ist kein Anmeldedienst eingerichtet. Die Anmeldung + läuft über Benutzername und Passwort. +
+ ) : ( + <> +

+ Verknüpfe dein Konto mit einem Anmeldedienst, um dich künftig + darüber anzumelden. Der Passwort-Login bleibt daneben bestehen. +

+
+ + + + + + {anbieter.map((a) => { + const v = verknuepftMit(a.slug); + return ( + + + + + + + ); + })} + {verwaiste.map((v) => ( + + + + + + + ))} + +
DienstVerknüpft seitZuletzt genutzt
{a.display_name} + {v ? datum(v.created_at) : –} + + {v ? datum(v.last_login_at) : –} + + {v ? ( + + ) : ( + + )} +
+ {v.display_name} +
am Server nicht mehr eingerichtet
+
{datum(v.created_at)}{datum(v.last_login_at)} + +
+
+ + )} +
+
+ ); +} diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx index 0d55534..672ebcd 100644 --- a/web/src/pages/Login.jsx +++ b/web/src/pages/Login.jsx @@ -1,9 +1,25 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import BrandMark from "../components/BrandMark"; +// Der Server schickt nur eine Kennung zurueck (`/login#sso_error=…`), keinen +// Klartext – interne Einzelheiten bleiben im Serverprotokoll. +const SSO_FEHLERTEXTE = { + abgebrochen: "Die Anmeldung wurde abgebrochen.", + sitzung_abgelaufen: "Die Anmeldung hat zu lange gedauert. Bitte noch einmal versuchen.", + state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.", + unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.", + anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.", + kein_konto: + "Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss es " + + "anlegen und die E-Mail-Adresse hinterlegen.", + konto_bereits_verknuepft: + "Dieses Konto ist bereits mit einer anderen Kennung des Anmeldedienstes verknüpft.", +}; + export default function Login() { const { login, abgelaufen } = useAuth(); const navigate = useNavigate(); @@ -11,6 +27,27 @@ export default function Login() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [anbieter, setAnbieter] = useState([]); + + // Fehlermeldung aus dem Single-Sign-on-Rueckweg einsammeln und die Adresszeile + // wieder saeubern. + useEffect(() => { + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, "")); + const kennung = fragment.get("sso_error"); + if (!kennung) return; + setError(SSO_FEHLERTEXTE[kennung] || "Die Anmeldung über den Anmeldedienst ist fehlgeschlagen."); + window.history.replaceState(null, "", window.location.pathname); + }, []); + + // Ohne eingerichteten Anbieter bleibt die Liste leer und es erscheint kein + // zusaetzlicher Knopf. Ein Fehler hier darf den Passwort-Login nicht stoeren. + useEffect(() => { + let aktuell = true; + api.oidcProviders() + .then((liste) => { if (aktuell) setAnbieter(liste || []); }) + .catch(() => {}); + return () => { aktuell = false; }; + }, []); async function onSubmit(e) { e.preventDefault(); @@ -52,6 +89,24 @@ export default function Login() { + + {anbieter.length > 0 && ( + <> +
oder
+ {anbieter.map((p) => ( + // Bewusst ein echter Link und kein fetch: der Wechsel zum + // Anmeldedienst muss eine Seitennavigation des Browsers sein. + + Mit {p.display_name} anmelden + + ))} + + )}
); diff --git a/web/src/pages/OidcCallback.jsx b/web/src/pages/OidcCallback.jsx new file mode 100644 index 0000000..da94381 --- /dev/null +++ b/web/src/pages/OidcCallback.jsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../auth"; +import Icon from "../components/Icon"; + +/** + * Rueckweg vom Single-Sign-on: Der Server haengt das fertige Vorrania-Token als + * Fragment an (`/auth/callback#token=…`). + * + * Fragment statt Abfragezeichenkette, weil der Browser Fragmente NICHT an den + * Server schickt – das Token landet damit weder im nginx-Zugriffsprotokoll noch + * in einem Proxy-Log. Direkt nach dem Lesen wird es aus der Adresszeile geraeumt, + * damit es auch nicht im Verlauf stehen bleibt. + */ +export default function OidcCallback() { + const { tokenUebernehmen } = useAuth(); + const navigate = useNavigate(); + const [fehler, setFehler] = useState(null); + // React 18 ruft Effekte im Entwicklungsmodus doppelt auf – ohne diesen Riegel + // liefe die Uebernahme zweimal, das zweite Mal mit bereits geleertem Fragment. + const erledigt = useRef(false); + + useEffect(() => { + if (erledigt.current) return; + erledigt.current = true; + + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, "")); + const token = fragment.get("token"); + + // Adresszeile sofort saeubern, egal wie es ausgeht. + window.history.replaceState(null, "", window.location.pathname); + + if (!token) { + setFehler("Die Anmeldung kam ohne Token zurück."); + return; + } + + tokenUebernehmen(token) + .then(() => navigate("/", { replace: true })) + .catch((err) => setFehler(err.message || "Anmeldung fehlgeschlagen.")); + }, [tokenUebernehmen, navigate]); + + if (fehler) { + return ( +
+
+
+ + {fehler} +
+ +
+
+ ); + } + + return
Anmeldung wird abgeschlossen…
; +} diff --git a/web/src/pages/Users.jsx b/web/src/pages/Users.jsx index 0b9dccb..3e5fdc6 100644 --- a/web/src/pages/Users.jsx +++ b/web/src/pages/Users.jsx @@ -10,7 +10,7 @@ export default function Users() { const { user: me } = useAuth(); const toast = useToast(); const [users, setUsers] = useState([]); - const [form, setForm] = useState({ username: "", password: "", role: "user" }); + const [form, setForm] = useState({ username: "", password: "", email: "", role: "user" }); const [error, setError] = useState(null); async function load() { @@ -28,7 +28,7 @@ export default function Users() { setError(null); try { await api.createUser(form); - setForm({ username: "", password: "", role: "user" }); + setForm({ username: "", password: "", email: "", role: "user" }); toast("Benutzer angelegt."); load(); } catch (err) { @@ -36,6 +36,21 @@ export default function Users() { } } + // Die Adresse ist der Schluessel fuer die erste SSO-Anmeldung: nur ueber sie + // findet ein Anmeldedienst das passende Bestandskonto. + async function changeEmail(u, email) { + if ((u.email || "") === email.trim()) return; + setError(null); + try { + await api.updateUser(u.id, { email: email.trim() }); + toast("E-Mail-Adresse gespeichert."); + load(); + } catch (err) { + setError(err.message); + load(); + } + } + async function changeRole(u, role) { try { await api.updateUser(u.id, { role }); @@ -75,13 +90,21 @@ export default function Users() {
- + {users.map((u) => ( +
NameRolle
NameE-MailRolle
{u.username}{u.id === me.id && (du)} + changeEmail(u, e.target.value)} + /> + setForm({ ...form, password: e.target.value })} required minLength={4} /> +