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:
Scarriffle
2026-09-09 21:55:38 +02:00
co-authored by Claude Opus 5
parent 8d4954610b
commit 3d0ff54bf5
35 changed files with 2772 additions and 24 deletions
+74
View File
@@ -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
+6
View File
@@ -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.
+10
View File
@@ -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
View File
@@ -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
+192
View File
@@ -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
+333
View File
@@ -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
+32
View File
@@ -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()
+56
View File
@@ -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).
+6 -1
View File
@@ -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"))
+466
View File
@@ -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
+83
View File
@@ -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)
+3
View File
@@ -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
+412
View File
@@ -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
+27
View File
@@ -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=
+10
View File
@@ -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"
+33
View File
@@ -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"
+7
View 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:
+85 -7
View File
@@ -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 }
try check(response, data: data)
return data
do {
try check(response, data: data)
return data
} catch APIError.unauthorized {
return try await sendeMitErneuerung(request)
}
}
/// Eigenes Foto (Kamera/Galerie) als multipart hochladen.
@@ -330,8 +409,7 @@ actor APIClient {
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
private func sendNoContent(_ request: URLRequest) async throws {
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
_ = try await sendeMitErneuerung(request)
}
// MARK: - Gegenstaende (Non-Food)
+54 -1
View File
@@ -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
+42
View File
@@ -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
+222
View File
@@ -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."
}
}
}
+9 -1
View File
@@ -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) }
+25 -4
View File
@@ -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) {
+8 -1
View File
@@ -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) }
}
+40
View File
@@ -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
}
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+214
View File
@@ -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
View File
@@ -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>
);
+60
View File
@@ -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
View File
@@ -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 })}>
+23
View File
@@ -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); }