Anmeldung wahlweise ueber OpenID Connect (Authentik)
Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg: OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher. Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die Rollenpruefung und die langlebigen API-Tokens unveraendert. Backend - Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung. - Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE) enden beide im selben Token-Schema. - state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt. Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf bleibt mit der db-Fixture pruefbar. - Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein gleichlautender preferred_username uebernimmt nie ein bestehendes Konto. - users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password faengt das ab, sonst antwortete der Login mit 500 statt 401. Verknuepfen bestehender Konten - Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es braucht also weder eine hinterlegte Adresse noch Admin-Rechte. - Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token nicht mitschicken kann. - Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen. iOS - AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem eingebetteten WebView, und ohne Client-Secret. - offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und bekaeme sonst einen ueberfluessigen Zustimmungsdialog. - Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung. Deployment - docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht erfordern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8d4954610b
commit
3d0ff54bf5
@@ -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://<server-ip>: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://<deine-adresse>/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://<server>: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_<KÜRZEL>_…`) – 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+77
-1
@@ -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
|
||||
|
||||
@@ -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/<UID>).
|
||||
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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 <PUBLIC_BASE_URL>/api/auth/oidc/authentik/callback.
|
||||
#OIDC_AUTHENTIK_REDIRECT_URI=
|
||||
|
||||
@@ -24,12 +24,22 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Die Single-Sign-on-Bloecke heissen je Anbieter anders (OIDC_<KUERZEL>_...).
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -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 <PUBLIC_BASE_URL>/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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -37,9 +37,63 @@ actor APIClient {
|
||||
}
|
||||
|
||||
private func send<T: Decodable>(_ 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<Bool, Never>?
|
||||
|
||||
private func erneuereAnmeldung() async -> Bool {
|
||||
if let laufend = laufendeErneuerung { return await laufend.value }
|
||||
let aufgabe = Task<Bool, Never> { 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 }
|
||||
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)
|
||||
|
||||
@@ -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<UUID> {
|
||||
@@ -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
|
||||
|
||||
@@ -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/<slug>/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
|
||||
|
||||
@@ -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<String, Error>] = [:]
|
||||
|
||||
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<String, Error> { [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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
||||
9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCLogin.swift; sourceTree = "<group>"; };
|
||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
|
||||
A75926511854BB8EE316ED3A /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
|
||||
@@ -90,6 +93,17 @@
|
||||
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
/* 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 */;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -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() {
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<div className="sidebar-user">
|
||||
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
|
||||
<NavLink to="/konto" className="sidebar-user" title="Mein Konto">
|
||||
<span className="name">{user?.username}</span>
|
||||
<span className="role">{isAdmin ? "Administrator" : "Benutzer"}</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
<button className="btn-icon" onClick={handleLogout} title="Abmelden">
|
||||
<Icon name="logout" size={18} />
|
||||
</button>
|
||||
@@ -187,6 +190,8 @@ export default function App() {
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
||||
<Route path="/konto" element={<Protected><Konto /></Protected>} />
|
||||
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
|
||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
|
||||
@@ -194,6 +199,10 @@ export default function App() {
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||
{/* 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. */}
|
||||
<Route path="/auth/callback" element={<OidcCallback />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
+15
-1
@@ -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) => {
|
||||
|
||||
+21
-1
@@ -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 (
|
||||
<AuthContext.Provider value={{
|
||||
user, isAdmin, loading, login, logout,
|
||||
user, isAdmin, loading, login, logout, tokenUebernehmen,
|
||||
abgelaufen, startFehler, erneutVersuchen,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
// Kennungen aus dem Rueckweg des Anmeldedienstes (`/konto#sso_error=…`).
|
||||
const SSO_FEHLERTEXTE = {
|
||||
abgebrochen: "Die Verknüpfung wurde abgebrochen.",
|
||||
sitzung_abgelaufen: "Der Vorgang hat zu lange gedauert. Bitte noch einmal versuchen.",
|
||||
state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.",
|
||||
unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.",
|
||||
anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.",
|
||||
kennung_fremd:
|
||||
"Diese Anmeldung gehört bereits zu einem anderen Vorrania-Konto. " +
|
||||
"Melde dich dort ab oder verwende eine andere Kennung.",
|
||||
schon_verknuepft: "Dieses Konto ist mit dem Anmeldedienst bereits verknüpft.",
|
||||
konto_verschwunden: "Das Konto gibt es nicht mehr.",
|
||||
};
|
||||
|
||||
function datum(wert) {
|
||||
if (!wert) return "–";
|
||||
return new Date(wert).toLocaleDateString("de-CH", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* „Mein Konto": zeigt, wie man sich an diesem Server anmelden kann, und
|
||||
* verknüpft das Konto mit einem Anmeldedienst (Single Sign-on).
|
||||
*
|
||||
* Der Weg über diese Seite ist der einzige, der **ohne** hinterlegte
|
||||
* E-Mail-Adresse funktioniert: Die Berechtigung stammt aus der laufenden
|
||||
* Sitzung, nicht aus einem Claim des Anmeldedienstes.
|
||||
*/
|
||||
export default function Konto() {
|
||||
const { user } = useAuth();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const [anbieter, setAnbieter] = useState([]);
|
||||
const [verknuepfungen, setVerknuepfungen] = useState([]);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [laedt, setLaedt] = useState(true);
|
||||
const [busy, setBusy] = useState(null);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [liste, meine] = await Promise.all([
|
||||
api.oidcProviders().catch(() => []),
|
||||
api.oidcIdentities(),
|
||||
]);
|
||||
setAnbieter(liste || []);
|
||||
setVerknuepfungen(meine || []);
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setLaedt(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { laden(); }, []);
|
||||
|
||||
// Rueckmeldung aus dem Rueckweg einsammeln und die Adresszeile saeubern.
|
||||
useEffect(() => {
|
||||
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const verknuepft = fragment.get("sso_linked");
|
||||
const kennung = fragment.get("sso_error");
|
||||
if (!verknuepft && !kennung) return;
|
||||
if (verknuepft) toast("Konto verknüpft.");
|
||||
if (kennung) {
|
||||
setFehler(SSO_FEHLERTEXTE[kennung] || "Die Verknüpfung ist fehlgeschlagen.");
|
||||
}
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}, [toast]);
|
||||
|
||||
async function verknuepfen(slug) {
|
||||
setFehler(null);
|
||||
setBusy(slug);
|
||||
try {
|
||||
// Erst die Adresse holen (mit Anmeldung im Header), dann dorthin
|
||||
// navigieren – ein einfacher Link könnte das Token nicht mitschicken.
|
||||
const { authorization_url } = await api.oidcLinkStart(slug);
|
||||
window.location.assign(authorization_url);
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loesen(slug, name) {
|
||||
const ok = await confirm({
|
||||
title: `Verknüpfung mit „${name}" lösen?`,
|
||||
message: "Danach ist die Anmeldung über diesen Dienst nicht mehr möglich.",
|
||||
confirmLabel: "Lösen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setFehler(null);
|
||||
setBusy(slug);
|
||||
try {
|
||||
await api.oidcUnlink(slug);
|
||||
toast("Verknüpfung gelöst.");
|
||||
await laden();
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const verknuepftMit = (slug) => verknuepfungen.find((v) => v.provider === slug);
|
||||
// Verknüpfungen zu Diensten, die es am Server nicht mehr gibt – lösen können
|
||||
// muss man sie trotzdem, sonst hängen sie unsichtbar am Konto.
|
||||
const verwaiste = verknuepfungen.filter(
|
||||
(v) => !anbieter.some((a) => a.slug === v.provider)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Mein Konto</h1>
|
||||
<div className="sub">Wie du dich an diesem Server anmeldest</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><Icon name="users" /><h2>Konto</h2></div>
|
||||
<dl className="kv">
|
||||
<dt>Benutzername</dt><dd>{user?.username}</dd>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>{user?.email || <span className="muted">nicht hinterlegt</span>}</dd>
|
||||
<dt>Rolle</dt>
|
||||
<dd>{user?.role === "admin" ? "Administrator" : "Benutzer"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
|
||||
<div className="card-head"><Icon name="settings" /><h2>Single Sign-on</h2></div>
|
||||
|
||||
{laedt ? (
|
||||
<div className="muted">Lädt…</div>
|
||||
) : anbieter.length === 0 && verwaiste.length === 0 ? (
|
||||
<div className="muted small">
|
||||
Auf diesem Server ist kein Anmeldedienst eingerichtet. Die Anmeldung
|
||||
läuft über Benutzername und Passwort.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted small mt-0">
|
||||
Verknüpfe dein Konto mit einem Anmeldedienst, um dich künftig
|
||||
darüber anzumelden. Der Passwort-Login bleibt daneben bestehen.
|
||||
</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Dienst</th><th>Verknüpft seit</th><th>Zuletzt genutzt</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anbieter.map((a) => {
|
||||
const v = verknuepftMit(a.slug);
|
||||
return (
|
||||
<tr key={a.slug}>
|
||||
<td data-label="Dienst" className="strong">{a.display_name}</td>
|
||||
<td data-label="Verknüpft seit">
|
||||
{v ? datum(v.created_at) : <span className="muted">–</span>}
|
||||
</td>
|
||||
<td data-label="Zuletzt genutzt">
|
||||
{v ? datum(v.last_login_at) : <span className="muted">–</span>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{v ? (
|
||||
<button className="btn" disabled={busy === a.slug}
|
||||
onClick={() => loesen(a.slug, a.display_name)}>
|
||||
Lösen
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn primary" disabled={busy === a.slug}
|
||||
onClick={() => verknuepfen(a.slug)}>
|
||||
{busy === a.slug ? "Weiterleiten…" : "Verknüpfen"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{verwaiste.map((v) => (
|
||||
<tr key={v.provider}>
|
||||
<td data-label="Dienst" className="strong">
|
||||
{v.display_name}
|
||||
<div className="muted small">am Server nicht mehr eingerichtet</div>
|
||||
</td>
|
||||
<td data-label="Verknüpft seit">{datum(v.created_at)}</td>
|
||||
<td data-label="Zuletzt genutzt">{datum(v.last_login_at)}</td>
|
||||
<td className="num">
|
||||
<button className="btn" disabled={busy === v.provider}
|
||||
onClick={() => loesen(v.provider, v.display_name)}>
|
||||
Lösen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+56
-1
@@ -1,9 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
|
||||
// Der Server schickt nur eine Kennung zurueck (`/login#sso_error=…`), keinen
|
||||
// Klartext – interne Einzelheiten bleiben im Serverprotokoll.
|
||||
const SSO_FEHLERTEXTE = {
|
||||
abgebrochen: "Die Anmeldung wurde abgebrochen.",
|
||||
sitzung_abgelaufen: "Die Anmeldung hat zu lange gedauert. Bitte noch einmal versuchen.",
|
||||
state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.",
|
||||
unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.",
|
||||
anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.",
|
||||
kein_konto:
|
||||
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss es " +
|
||||
"anlegen und die E-Mail-Adresse hinterlegen.",
|
||||
konto_bereits_verknuepft:
|
||||
"Dieses Konto ist bereits mit einer anderen Kennung des Anmeldedienstes verknüpft.",
|
||||
};
|
||||
|
||||
export default function Login() {
|
||||
const { login, abgelaufen } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -11,6 +27,27 @@ export default function Login() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [anbieter, setAnbieter] = useState([]);
|
||||
|
||||
// Fehlermeldung aus dem Single-Sign-on-Rueckweg einsammeln und die Adresszeile
|
||||
// wieder saeubern.
|
||||
useEffect(() => {
|
||||
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const kennung = fragment.get("sso_error");
|
||||
if (!kennung) return;
|
||||
setError(SSO_FEHLERTEXTE[kennung] || "Die Anmeldung über den Anmeldedienst ist fehlgeschlagen.");
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}, []);
|
||||
|
||||
// Ohne eingerichteten Anbieter bleibt die Liste leer und es erscheint kein
|
||||
// zusaetzlicher Knopf. Ein Fehler hier darf den Passwort-Login nicht stoeren.
|
||||
useEffect(() => {
|
||||
let aktuell = true;
|
||||
api.oidcProviders()
|
||||
.then((liste) => { if (aktuell) setAnbieter(liste || []); })
|
||||
.catch(() => {});
|
||||
return () => { aktuell = false; };
|
||||
}, []);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -52,6 +89,24 @@ export default function Login() {
|
||||
<button className="btn primary" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
|
||||
{busy ? "Anmelden…" : "Anmelden"}
|
||||
</button>
|
||||
|
||||
{anbieter.length > 0 && (
|
||||
<>
|
||||
<div className="login-trenner">oder</div>
|
||||
{anbieter.map((p) => (
|
||||
// Bewusst ein echter Link und kein fetch: der Wechsel zum
|
||||
// Anmeldedienst muss eine Seitennavigation des Browsers sein.
|
||||
<a
|
||||
key={p.slug}
|
||||
className="btn"
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
href={`/api/auth/oidc/${encodeURIComponent(p.slug)}/login`}
|
||||
>
|
||||
Mit {p.display_name} anmelden
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
/**
|
||||
* Rueckweg vom Single-Sign-on: Der Server haengt das fertige Vorrania-Token als
|
||||
* Fragment an (`/auth/callback#token=…`).
|
||||
*
|
||||
* Fragment statt Abfragezeichenkette, weil der Browser Fragmente NICHT an den
|
||||
* Server schickt – das Token landet damit weder im nginx-Zugriffsprotokoll noch
|
||||
* in einem Proxy-Log. Direkt nach dem Lesen wird es aus der Adresszeile geraeumt,
|
||||
* damit es auch nicht im Verlauf stehen bleibt.
|
||||
*/
|
||||
export default function OidcCallback() {
|
||||
const { tokenUebernehmen } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [fehler, setFehler] = useState(null);
|
||||
// React 18 ruft Effekte im Entwicklungsmodus doppelt auf – ohne diesen Riegel
|
||||
// liefe die Uebernahme zweimal, das zweite Mal mit bereits geleertem Fragment.
|
||||
const erledigt = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (erledigt.current) return;
|
||||
erledigt.current = true;
|
||||
|
||||
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const token = fragment.get("token");
|
||||
|
||||
// Adresszeile sofort saeubern, egal wie es ausgeht.
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
|
||||
if (!token) {
|
||||
setFehler("Die Anmeldung kam ohne Token zurück.");
|
||||
return;
|
||||
}
|
||||
|
||||
tokenUebernehmen(token)
|
||||
.then(() => navigate("/", { replace: true }))
|
||||
.catch((err) => setFehler(err.message || "Anmeldung fehlgeschlagen."));
|
||||
}, [tokenUebernehmen, navigate]);
|
||||
|
||||
if (fehler) {
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<div className="card login-card">
|
||||
<div className="alert error">
|
||||
<Icon name="alert" size={16} />
|
||||
{fehler}
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => navigate("/login", { replace: true })}>
|
||||
Zurück zur Anmeldung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="center muted" style={{ padding: 60 }}>Anmeldung wird abgeschlossen…</div>;
|
||||
}
|
||||
+34
-3
@@ -10,7 +10,7 @@ export default function Users() {
|
||||
const { user: me } = useAuth();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [form, setForm] = useState({ username: "", password: "", role: "user" });
|
||||
const [form, setForm] = useState({ username: "", password: "", email: "", role: "user" });
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
@@ -28,7 +28,7 @@ export default function Users() {
|
||||
setError(null);
|
||||
try {
|
||||
await api.createUser(form);
|
||||
setForm({ username: "", password: "", role: "user" });
|
||||
setForm({ username: "", password: "", email: "", role: "user" });
|
||||
toast("Benutzer angelegt.");
|
||||
load();
|
||||
} catch (err) {
|
||||
@@ -36,6 +36,21 @@ export default function Users() {
|
||||
}
|
||||
}
|
||||
|
||||
// Die Adresse ist der Schluessel fuer die erste SSO-Anmeldung: nur ueber sie
|
||||
// findet ein Anmeldedienst das passende Bestandskonto.
|
||||
async function changeEmail(u, email) {
|
||||
if ((u.email || "") === email.trim()) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateUser(u.id, { email: email.trim() });
|
||||
toast("E-Mail-Adresse gespeichert.");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(u, role) {
|
||||
try {
|
||||
await api.updateUser(u.id, { role });
|
||||
@@ -75,13 +90,21 @@ export default function Users() {
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
|
||||
<thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td data-label="Name" className="strong">
|
||||
{u.username}{u.id === me.id && <span className="muted"> (du)</span>}
|
||||
</td>
|
||||
<td data-label="E-Mail">
|
||||
<input
|
||||
type="email"
|
||||
defaultValue={u.email || ""}
|
||||
placeholder="für SSO"
|
||||
onBlur={(e) => changeEmail(u, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td data-label="Rolle">
|
||||
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
|
||||
disabled={u.id === me.id}>
|
||||
@@ -114,6 +137,14 @@ export default function Users() {
|
||||
<input type="password" value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} />
|
||||
</label>
|
||||
<label>
|
||||
E-Mail (optional)
|
||||
<input type="email" value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
<span className="muted small">
|
||||
Nur nötig, damit sich dieses Konto per Single Sign-on anmelden kann.
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
Rolle
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
|
||||
@@ -164,6 +164,9 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
}
|
||||
.sidebar-user { display: flex; flex-direction: column; min-width: 0; }
|
||||
/* Der Name ist ein Link zum eigenen Konto, soll aber weiter wie Text wirken. */
|
||||
a.sidebar-user { color: inherit; text-decoration: none; }
|
||||
a.sidebar-user:hover .name { text-decoration: underline; }
|
||||
.sidebar-user .name { font-weight: 600; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sidebar-user .role { font-size: 0.72rem; color: var(--muted); }
|
||||
|
||||
@@ -971,6 +974,26 @@ select.zeitraum:hover { color: var(--text); }
|
||||
.login-card { width: 100%; max-width: 360px; }
|
||||
.login-card .sidebar-brand { padding: 0 0 var(--sp-2); font-size: 1.2rem; }
|
||||
.login-card .lead { color: var(--muted); font-size: 0.9rem; margin: 0 0 var(--sp-5); }
|
||||
/* Trennt Passwort-Anmeldung und Single-Sign-on: Strich links und rechts vom Wort. */
|
||||
.login-trenner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) 0 var(--sp-3);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.login-trenner::before,
|
||||
.login-trenner::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Beschreibungsliste (Feld -> Wert), z. B. auf der Kontoseite. */
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-5); margin: 0; }
|
||||
.kv dt { color: var(--muted); font-size: 0.85rem; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
/* ---------- Utilities ---------- */
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
Reference in New Issue
Block a user