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
+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