Anmeldung wahlweise ueber OpenID Connect (Authentik)
Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg: OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher. Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die Rollenpruefung und die langlebigen API-Tokens unveraendert. Backend - Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung. - Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE) enden beide im selben Token-Schema. - state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt. Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf bleibt mit der db-Fixture pruefbar. - Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein gleichlautender preferred_username uebernimmt nie ein bestehendes Konto. - users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password faengt das ab, sonst antwortete der Login mit 500 statt 401. Verknuepfen bestehender Konten - Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es braucht also weder eine hinterlegte Adresse noch Admin-Rechte. - Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token nicht mitschicken kann. - Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen. iOS - AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem eingebetteten WebView, und ohne Client-Secret. - offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und bekaeme sonst einen ueberfluessigen Zustimmungsdialog. - Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung. Deployment - docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht erfordern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8d4954610b
commit
3d0ff54bf5
@@ -18,6 +18,12 @@ class Settings(BaseSettings):
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "changeme"
|
||||
|
||||
# Oeffentliche Adresse der Instanz, z.B. "https://vorrania.example.com".
|
||||
# Wird fuer die OIDC-Rueckleit-Adresse gebraucht und laesst sich NICHT aus dem
|
||||
# Request ableiten: das /api-Praefix entsteht erst in nginx, und es gibt weder
|
||||
# root_path noch ProxyHeadersMiddleware.
|
||||
public_base_url: str = ""
|
||||
|
||||
# Open Food Facts (Lebensmittel)
|
||||
off_base_url: str = "https://world.openfoodfacts.org"
|
||||
# Open Products Facts (allgemeine Produkte / Gegenstände) – gleiche API-Struktur.
|
||||
|
||||
@@ -17,6 +17,7 @@ from .routers import (
|
||||
items,
|
||||
locations,
|
||||
maintenance,
|
||||
oidc,
|
||||
package_types,
|
||||
products,
|
||||
settings as settings_router,
|
||||
@@ -212,6 +213,14 @@ def _ensure_schema() -> None:
|
||||
# Vorschaubild (Briefmarkengröße) neben dem Original. Bestehende Bilder
|
||||
# bekommen es beim ersten Abruf; deshalb nullbar und ohne Umzug.
|
||||
"ALTER TABLE product_images ADD COLUMN IF NOT EXISTS thumb BYTEA",
|
||||
# Single Sign-on: E-Mail am Benutzer (nur fuer die Erstverknuepfung) und
|
||||
# ein Passwort-Hash, der bei reinen SSO-Konten leer bleiben darf.
|
||||
# DROP NOT NULL ist auf einer bereits nullbaren Spalte ein No-Op, der
|
||||
# Aufruf also wiederholbar.
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
|
||||
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
|
||||
"WHERE email IS NOT NULL",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||
@@ -277,6 +286,7 @@ def health() -> dict:
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(oidc.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(stock.router)
|
||||
|
||||
+77
-1
@@ -118,10 +118,86 @@ class User(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# Leer bei Konten, die es nur ueber Single Sign-on gibt. Ein solches Konto ist
|
||||
# per Passwort schlicht nicht anmeldbar (siehe routers/auth.py).
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Nur fuer die Erstverknuepfung mit einem SSO-Konto. Bewusst nicht Pflicht:
|
||||
# bestehende Installationen haben keine Adressen hinterlegt.
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
identities: Mapped[list[UserIdentity]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal
|
||||
# uebernimmt – zwei Konten mit derselben Adresse waeren also mehrdeutig.
|
||||
# Gross-/Kleinschreibung darf dabei keinen Unterschied machen, und leere
|
||||
# Adressen (der Normalfall bei Passwort-Konten) bleiben aussen vor.
|
||||
Index(
|
||||
"uq_users_email",
|
||||
text("lower(email)"),
|
||||
unique=True,
|
||||
postgresql_where=text("email IS NOT NULL"),
|
||||
sqlite_where=text("email IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OidcLoginState(Base):
|
||||
"""Merkzettel zwischen „zum Anmeldedienst geschickt" und „zurueckgekommen".
|
||||
|
||||
Bewusst eine Tabelle und kein Sitzungs-Cookie. Dieses Backend kennt sonst
|
||||
gar keine Cookies, und Starlettes CORS-Schicht wechselt bei
|
||||
``allow_origins=["*"]`` **zusammen mit** ``allow_credentials=True`` auf die
|
||||
gespiegelte Herkunft, sobald ein Request ueberhaupt ein Cookie mitbringt
|
||||
(starlette/middleware/cors.py). Ohne Cookie entsteht diese Wechselwirkung
|
||||
erst gar nicht – und mit der ``db``-Fixture laesst sich der Ablauf pruefen,
|
||||
was bei einer Middleware ohne TestClient nicht ginge.
|
||||
"""
|
||||
|
||||
__tablename__ = "oidc_login_states"
|
||||
|
||||
state: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# Gesetzt, wenn der Vorgang eine VERKNUEPFUNG ist statt einer Anmeldung: Der
|
||||
# Rueckweg haengt die Identitaet dann an genau dieses (bereits angemeldete)
|
||||
# Konto, statt jemanden anzumelden. Die Berechtigung dafuer stammt aus der
|
||||
# laufenden Vorrania-Sitzung, nicht aus der E-Mail-Adresse.
|
||||
link_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class UserIdentity(Base):
|
||||
"""Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter.
|
||||
|
||||
Eigene Tabelle statt zweier Spalten am Benutzer, damit ein Konto spaeter
|
||||
Identitaeten bei MEHREREN Anbietern haben kann (Authentik heute, etwas
|
||||
anderes morgen) – genau die Erweiterbarkeit, um die es beim OIDC-Umbau geht.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_identities"
|
||||
__table_args__ = (UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Das Kuerzel aus OIDC_PROVIDERS, z.B. "authentik".
|
||||
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# Die sub-Angabe des Anbieters – stabil, auch wenn sich die E-Mail aendert.
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="identities")
|
||||
|
||||
|
||||
# Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht
|
||||
# nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""OIDC-Anmeldeanbieter aus Umgebungsvariablen lesen.
|
||||
|
||||
Ein Anbieter wird NICHT im Code hinterlegt, sondern über die Umgebung beschrieben.
|
||||
``OIDC_PROVIDERS`` zaehlt die Kuerzel auf, jedes Kuerzel bekommt einen eigenen
|
||||
Block::
|
||||
|
||||
OIDC_PROVIDERS=authentik
|
||||
OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration
|
||||
OIDC_AUTHENTIK_CLIENT_ID=...
|
||||
OIDC_AUTHENTIK_CLIENT_SECRET=...
|
||||
|
||||
Ein zweiter Anbieter ist damit reine Konfiguration (``OIDC_PROVIDERS=authentik,keycloak``
|
||||
plus zweiter Block) – ohne Codeaenderung. ``pydantic-settings`` kann solche
|
||||
dynamischen Praefixe nicht, deshalb dieser eigene kleine Leser statt eines Feldes
|
||||
in ``Settings``.
|
||||
|
||||
Fehlt die Konfiguration ganz, ist die Liste leer und die Anwendung laeuft wie
|
||||
bisher rein mit Passwort-Login weiter. Das ist wichtig, weil ``deploy/update.sh``
|
||||
bestehende ``.env``-Dateien nicht nachtraegt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Der Browser-Weg braucht KEIN Refresh-Token: das Backend loest den Code ein,
|
||||
# stellt sein eigenes Vorrania-Token aus und wirft den Rest weg. offline_access
|
||||
# hier anzufordern wuerde nur einen zusaetzlichen Zustimmungsdialog ausloesen.
|
||||
STANDARD_SCOPES = "openid profile email"
|
||||
|
||||
# Die App dagegen haelt die Anmeldung selbst und erneuert sie im Hintergrund.
|
||||
# offline_access ist dafuer Pflicht und kein Beiwerk: Authentik liefert seit
|
||||
# 2024.2 ohne diesen Scope KEIN Refresh-Token.
|
||||
STANDARD_NATIVE_SCOPES = "openid profile email offline_access"
|
||||
|
||||
# Custom-URL-Scheme der iOS-App. Der Scheme "vorrania" ist in der Info.plist
|
||||
# bereits registriert (dort fuer vorrania://checkin und vorrania://i/<UID>).
|
||||
STANDARD_NATIVE_REDIRECT = "vorrania://oauth/callback"
|
||||
|
||||
_ERLAUBTES_KUERZEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OidcProvider:
|
||||
"""Ein konfigurierter Anmeldeanbieter."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
discovery_url: str
|
||||
client_id: str
|
||||
# Nur der vertrauliche Web-Client hat ein Secret. Die App ist ein oeffentlicher
|
||||
# Client und sendet nie eins – deshalb steht es hier auch nur fuer den Web-Weg.
|
||||
client_secret: str = ""
|
||||
scopes: str = STANDARD_SCOPES
|
||||
native_scopes: str = STANDARD_NATIVE_SCOPES
|
||||
# Client-IDs der nativen (oeffentlichen) Clients. Ein ID-Token aus der App
|
||||
# traegt eine ANDERE aud als der Web-Client, deshalb eine eigene Liste.
|
||||
native_client_ids: tuple[str, ...] = ()
|
||||
native_redirect_uri: str = STANDARD_NATIVE_REDIRECT
|
||||
redirect_uri: str = ""
|
||||
auto_create: bool = True
|
||||
# Verknuepfung mit einem bestehenden Konto nur bei bestaetigter E-Mail. Wer das
|
||||
# abschaltet, erlaubt einem IdP-Konto mit frei gewaehlter Adresse die Uebernahme
|
||||
# eines fremden Vorrania-Kontos.
|
||||
require_verified_email: bool = True
|
||||
|
||||
@property
|
||||
def erlaubte_audiences(self) -> tuple[str, ...]:
|
||||
"""Alle aud-Werte, die ein ID-Token dieses Anbieters tragen darf."""
|
||||
return (self.client_id, *self.native_client_ids)
|
||||
|
||||
|
||||
def _wahrheitswert(rohwert: str | None, standard: bool) -> bool:
|
||||
if rohwert is None or not rohwert.strip():
|
||||
return standard
|
||||
return rohwert.strip().lower() in {"1", "true", "yes", "on", "ja"}
|
||||
|
||||
|
||||
def _liste(rohwert: str | None) -> tuple[str, ...]:
|
||||
if not rohwert:
|
||||
return ()
|
||||
return tuple(t.strip() for t in rohwert.replace(";", ",").split(",") if t.strip())
|
||||
|
||||
|
||||
def _praefix(slug: str) -> str:
|
||||
""""authentik" -> "OIDC_AUTHENTIK_", "my-idp" -> "OIDC_MY_IDP_"."""
|
||||
return "OIDC_" + slug.upper().replace("-", "_") + "_"
|
||||
|
||||
|
||||
def provider_aus_umgebung(
|
||||
env: Mapping[str, str], public_base_url: str = ""
|
||||
) -> list[OidcProvider]:
|
||||
"""Liest alle konfigurierten Anbieter.
|
||||
|
||||
Unvollstaendig konfigurierte Anbieter werden uebersprungen und protokolliert –
|
||||
ein Tippfehler in der ``.env`` darf den Serverstart nicht verhindern.
|
||||
"""
|
||||
anbieter: list[OidcProvider] = []
|
||||
basis = public_base_url.rstrip("/")
|
||||
|
||||
for roh in _liste(env.get("OIDC_PROVIDERS")):
|
||||
slug = roh.strip().lower()
|
||||
if not _ERLAUBTES_KUERZEL.match(slug):
|
||||
logger.warning(
|
||||
"OIDC-Anbieter '%s' uebersprungen: Kuerzel darf nur Kleinbuchstaben, "
|
||||
"Ziffern und Bindestriche enthalten.",
|
||||
roh,
|
||||
)
|
||||
continue
|
||||
if any(a.slug == slug for a in anbieter):
|
||||
logger.warning("OIDC-Anbieter '%s' doppelt genannt – zweiter ignoriert.", slug)
|
||||
continue
|
||||
|
||||
p = _praefix(slug)
|
||||
discovery = (env.get(p + "DISCOVERY_URL") or "").strip()
|
||||
client_id = (env.get(p + "CLIENT_ID") or "").strip()
|
||||
if not discovery or not client_id:
|
||||
logger.warning(
|
||||
"OIDC-Anbieter '%s' uebersprungen: %sDISCOVERY_URL und %sCLIENT_ID "
|
||||
"muessen gesetzt sein.",
|
||||
slug,
|
||||
p,
|
||||
p,
|
||||
)
|
||||
continue
|
||||
|
||||
redirect = (env.get(p + "REDIRECT_URI") or "").strip()
|
||||
if not redirect:
|
||||
if not basis:
|
||||
logger.warning(
|
||||
"OIDC-Anbieter '%s' uebersprungen: ohne PUBLIC_BASE_URL laesst sich "
|
||||
"die Rueckleit-Adresse nicht bilden (oder %sREDIRECT_URI setzen).",
|
||||
slug,
|
||||
p,
|
||||
)
|
||||
continue
|
||||
# Das /api-Praefix entsteht erst in nginx, nicht in FastAPI – deshalb
|
||||
# gehoert es hier fest hinein und darf nicht aus dem Request kommen.
|
||||
redirect = f"{basis}/api/auth/oidc/{slug}/callback"
|
||||
|
||||
def _scopes(schluessel: str, standard: str) -> str:
|
||||
wert = (env.get(p + schluessel) or "").strip() or standard
|
||||
# Ohne openid ist es kein OpenID Connect, sondern blosses OAuth –
|
||||
# dann kaeme gar kein ID-Token zurueck.
|
||||
return wert if "openid" in wert.split() else "openid " + wert
|
||||
|
||||
scopes = _scopes("SCOPES", STANDARD_SCOPES)
|
||||
native_scopes = _scopes("NATIVE_SCOPES", STANDARD_NATIVE_SCOPES)
|
||||
|
||||
anbieter.append(
|
||||
OidcProvider(
|
||||
slug=slug,
|
||||
display_name=(env.get(p + "DISPLAY_NAME") or "").strip() or slug.capitalize(),
|
||||
discovery_url=discovery,
|
||||
client_id=client_id,
|
||||
client_secret=(env.get(p + "CLIENT_SECRET") or "").strip(),
|
||||
scopes=scopes,
|
||||
native_scopes=native_scopes,
|
||||
native_client_ids=_liste(env.get(p + "NATIVE_CLIENT_IDS")),
|
||||
native_redirect_uri=(env.get(p + "NATIVE_REDIRECT_URI") or "").strip()
|
||||
or STANDARD_NATIVE_REDIRECT,
|
||||
redirect_uri=redirect,
|
||||
auto_create=_wahrheitswert(env.get(p + "AUTO_CREATE"), True),
|
||||
require_verified_email=_wahrheitswert(
|
||||
env.get(p + "REQUIRE_VERIFIED_EMAIL"), True
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return anbieter
|
||||
|
||||
|
||||
@lru_cache
|
||||
def registrierte_provider() -> tuple[OidcProvider, ...]:
|
||||
"""Die Anbieter dieses Prozesses (einmal beim ersten Zugriff gelesen)."""
|
||||
from .config import get_settings
|
||||
|
||||
return tuple(provider_aus_umgebung(os.environ, get_settings().public_base_url))
|
||||
|
||||
|
||||
def provider_finden(slug: str) -> OidcProvider | None:
|
||||
for p in registrierte_provider():
|
||||
if p.slug == slug:
|
||||
return p
|
||||
return None
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Anmeldung ueber einen OpenID-Connect-Anbieter (z.B. Authentik).
|
||||
|
||||
Zwei Wege, ein Ergebnis: In beiden Faellen endet die Anmeldung mit einem ganz
|
||||
normalen Vorrania-Token aus :func:`create_access_token`. Dadurch bleiben
|
||||
``deps.py``, die Rollenpruefung und die langlebigen API-Tokens unveraendert –
|
||||
der Anbieter ist ausschliesslich fuer den Anmeldevorgang zustaendig.
|
||||
|
||||
* **Browser** (vertraulicher Client): ``/login`` leitet zum Anbieter,
|
||||
``/callback`` loest den Code mit dem Client-Secret ein.
|
||||
* **App** (oeffentlicher Client, PKCE): die App fuehrt den Flow mit AppAuth im
|
||||
System-Browser selbst durch und reicht nur ihr ID-Token bei ``/native`` ein.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import User, UserIdentity
|
||||
from ..oidc_config import OidcProvider, provider_finden, registrierte_provider
|
||||
from ..schemas import (
|
||||
OidcIdentityOut,
|
||||
OidcLinkStart,
|
||||
OidcNativeLogin,
|
||||
OidcProviderOut,
|
||||
Token,
|
||||
)
|
||||
from ..security import create_access_token
|
||||
from ..services import oidc as dienst
|
||||
from ..services import oidc_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth/oidc", tags=["auth"])
|
||||
|
||||
# Wohin die Weboberflaeche nach dem Rueckweg geschickt wird. Absichtlich relativ:
|
||||
# der Browser loest das gegen die oeffentliche Adresse auf, mit der er ohnehin
|
||||
# gerade spricht – eine korrekt gesetzte PUBLIC_BASE_URL braucht es dafuer nicht.
|
||||
WEB_ERFOLG = "/auth/callback"
|
||||
WEB_FEHLER = "/login"
|
||||
# Eine Verknuepfung endet dort, wo sie angestossen wurde – nicht auf der
|
||||
# Anmeldemaske: Der Nutzer ist ja die ganze Zeit angemeldet geblieben.
|
||||
WEB_KONTO = "/konto"
|
||||
|
||||
|
||||
def _provider_oder_404(slug: str) -> OidcProvider:
|
||||
provider = provider_finden(slug)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"Kein Anmeldeanbieter '{slug}' eingerichtet"
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _token_fuer(user: User) -> Token:
|
||||
token = create_access_token(subject=user.username, role=user.role.value)
|
||||
return Token(access_token=token, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=list[OidcProviderOut])
|
||||
def anbieter_auflisten() -> list[OidcProviderOut]:
|
||||
"""Welche Anmeldeanbieter eingerichtet sind – ohne Anmeldung abrufbar.
|
||||
|
||||
Die Weboberflaeche kennt keine Build-Variablen und die App muss die Angaben
|
||||
je Serverprofil erfragen; beide erfahren erst hier, ob es ueberhaupt einen
|
||||
SSO-Knopf zu zeigen gibt. Client-Secrets stehen hier nie drin.
|
||||
"""
|
||||
return [
|
||||
OidcProviderOut(
|
||||
slug=p.slug,
|
||||
display_name=p.display_name,
|
||||
discovery_url=p.discovery_url,
|
||||
native_client_id=p.native_client_ids[0] if p.native_client_ids else None,
|
||||
native_redirect_uri=p.native_redirect_uri,
|
||||
scopes=p.native_scopes,
|
||||
)
|
||||
for p in registrierte_provider()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{slug}/login")
|
||||
def anmeldung_starten(slug: str, db: Session = Depends(get_db)) -> RedirectResponse:
|
||||
"""Schickt den Browser zum Anbieter."""
|
||||
provider = _provider_oder_404(slug)
|
||||
# Abgebrochene Anmeldungen hinterlassen Zettel – hier ist der guenstigste
|
||||
# Moment, sie loszuwerden.
|
||||
oidc_state.aufraeumen(db)
|
||||
|
||||
zettel = oidc_state.anlegen(db, provider.slug)
|
||||
try:
|
||||
ziel = dienst.anmelde_url(
|
||||
provider,
|
||||
state=zettel.state,
|
||||
nonce=zettel.nonce,
|
||||
code_verifier=zettel.code_verifier,
|
||||
)
|
||||
except dienst.OidcFehler as fehler:
|
||||
logger.warning("SSO '%s' konnte nicht gestartet werden: %s", slug, fehler)
|
||||
return _fehler_weiterleitung(fehler.schluessel)
|
||||
|
||||
return RedirectResponse(ziel, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
def _fehler_weiterleitung(schluessel: str, verknuepfung: bool = False) -> RedirectResponse:
|
||||
# Nur die Kennung, kein Klartext: die Oberflaeche uebersetzt sie, und interne
|
||||
# Details bleiben im Serverprotokoll.
|
||||
ziel = WEB_KONTO if verknuepfung else WEB_FEHLER
|
||||
return RedirectResponse(
|
||||
f"{ziel}#sso_error={quote(schluessel)}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}/callback")
|
||||
def anmeldung_abschliessen(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
code: str | None = None,
|
||||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
"""Rueckweg vom Anbieter.
|
||||
|
||||
Zwei Ausgaenge, je nachdem womit der Vorgang gestartet wurde: eine Anmeldung
|
||||
endet mit einem Vorrania-Token, eine Verknuepfung haengt die Kennung an das
|
||||
bereits angemeldete Konto und laesst die Sitzung unberuehrt.
|
||||
"""
|
||||
provider = _provider_oder_404(slug)
|
||||
|
||||
if error:
|
||||
# Nutzer hat abgebrochen oder der Anbieter verweigert – kein Serverfehler.
|
||||
# Zu diesem Zeitpunkt ist noch unbekannt, ob es eine Verknuepfung war;
|
||||
# die Anmeldemaske ist der unschaedlichere Landeplatz.
|
||||
logger.info("SSO '%s' vom Anbieter abgelehnt: %s", slug, error)
|
||||
return _fehler_weiterleitung("abgebrochen")
|
||||
|
||||
if not code or not state:
|
||||
return _fehler_weiterleitung("unvollstaendige_antwort")
|
||||
|
||||
# Schutz vor untergeschobenen Anmeldungen: der Zettel muss von uns stammen,
|
||||
# darf nicht abgelaufen und noch nicht verbraucht sein. Das Einloesen
|
||||
# entfernt ihn, ein zweiter Rueckweg laeuft also ins Leere.
|
||||
gemerkt = oidc_state.einloesen(db, state)
|
||||
if gemerkt is None:
|
||||
logger.warning("SSO '%s': unbekannter oder abgelaufener state.", slug)
|
||||
return _fehler_weiterleitung("sitzung_abgelaufen")
|
||||
if not secrets.compare_digest(gemerkt.provider, slug):
|
||||
logger.warning("SSO '%s': state gehoert zu Anbieter '%s'.", slug, gemerkt.provider)
|
||||
return _fehler_weiterleitung("state_falsch")
|
||||
|
||||
verknuepfen_fuer = gemerkt.link_user_id
|
||||
|
||||
try:
|
||||
tokens = dienst.code_einloesen(provider, code=code, code_verifier=gemerkt.code_verifier)
|
||||
roh_id_token = tokens.get("id_token")
|
||||
if not roh_id_token:
|
||||
raise dienst.OidcFehler(
|
||||
"kein_id_token", "Der Anbieter hat kein ID-Token geliefert."
|
||||
)
|
||||
claims = dienst.pruefe_id_token(
|
||||
provider,
|
||||
roh_id_token,
|
||||
nonce=gemerkt.nonce,
|
||||
# Der Browser-Weg laeuft ueber den vertraulichen Client – ein Token
|
||||
# fuer die App-Client-ID darf hier nicht gelten.
|
||||
erlaubte_audiences=(provider.client_id,),
|
||||
)
|
||||
|
||||
if verknuepfen_fuer is not None:
|
||||
# Verknuepfung: Es wird niemand angemeldet. Wer das Konto ist, stand
|
||||
# schon beim Start fest (aus der damaligen Sitzung) – die Claims
|
||||
# liefern nur die Kennung, der wir es zuordnen.
|
||||
konto = db.get(User, verknuepfen_fuer)
|
||||
if konto is None:
|
||||
raise dienst.OidcFehler(
|
||||
"konto_verschwunden", "Das zu verknuepfende Konto gibt es nicht mehr."
|
||||
)
|
||||
dienst.identitaet_verknuepfen(db, konto, provider, claims)
|
||||
logger.info("SSO '%s': Verknuepfung fuer '%s' abgeschlossen.", slug, konto.username)
|
||||
return RedirectResponse(
|
||||
f"{WEB_KONTO}#sso_linked={quote(provider.slug)}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
nutzer = dienst.benutzer_aus_claims(db, provider, claims)
|
||||
except dienst.OidcFehler as fehler:
|
||||
logger.warning("SSO '%s' gescheitert: %s", slug, fehler)
|
||||
return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None)
|
||||
|
||||
token = _token_fuer(nutzer)
|
||||
logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username)
|
||||
# Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht
|
||||
# an den Server geschickt und landen damit auch nicht in den nginx-Logs.
|
||||
return RedirectResponse(
|
||||
f"{WEB_ERFOLG}#token={quote(token.access_token)}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/native", response_model=Token)
|
||||
def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get_db)) -> Token:
|
||||
"""Tauscht das ID-Token aus der App gegen ein Vorrania-Token.
|
||||
|
||||
Die App hat den Flow mit PKCE im System-Browser selbst gefuehrt; der Server
|
||||
prueft hier nur noch, dass das Token echt, unverfallen und **fuer einen der
|
||||
registrierten App-Clients ausgestellt** ist.
|
||||
"""
|
||||
provider = _provider_oder_404(slug)
|
||||
if not provider.native_client_ids:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Fuer '{slug}' ist kein App-Client eingerichtet",
|
||||
)
|
||||
try:
|
||||
claims = dienst.pruefe_id_token(
|
||||
provider,
|
||||
payload.id_token,
|
||||
# AppAuth prueft die nonce bereits auf dem Geraet. Schickt die App sie
|
||||
# mit, prueft der Server sie ein zweites Mal – erst das schliesst aus,
|
||||
# dass ein anderswo abgegriffenes ID-Token desselben Clients hier
|
||||
# eingereicht wird.
|
||||
nonce=payload.nonce,
|
||||
erlaubte_audiences=provider.native_client_ids,
|
||||
)
|
||||
nutzer = dienst.benutzer_aus_claims(db, provider, claims)
|
||||
except dienst.OidcFehler as fehler:
|
||||
logger.warning("SSO '%s' (App) gescheitert: %s", slug, fehler)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler
|
||||
|
||||
logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verknuepfung eines bestehenden Kontos (fuer bereits angemeldete Nutzer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/identities", response_model=list[OidcIdentityOut])
|
||||
def verknuepfungen_auflisten(
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> list[OidcIdentityOut]:
|
||||
"""Mit welchen Anmeldediensten das eigene Konto verknuepft ist."""
|
||||
eintraege = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.user_id == current_user.id)
|
||||
.order_by(UserIdentity.provider)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
OidcIdentityOut(
|
||||
provider=e.provider,
|
||||
# Ein inzwischen abgeschalteter Anbieter soll trotzdem sichtbar
|
||||
# bleiben – sonst haengt eine Verknuepfung unsichtbar am Konto.
|
||||
display_name=(p.display_name if (p := provider_finden(e.provider)) else e.provider),
|
||||
created_at=e.created_at,
|
||||
last_login_at=e.last_login_at,
|
||||
)
|
||||
for e in eintraege
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{slug}/link", response_model=OidcLinkStart)
|
||||
def verknuepfung_starten(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> OidcLinkStart:
|
||||
"""Startet die Verknuepfung des eigenen Kontos mit einem Anmeldedienst.
|
||||
|
||||
Bewusst POST und nicht einfach ein Link: Die Berechtigung steckt im
|
||||
Bearer-Token der laufenden Sitzung, und das laesst sich bei einer normalen
|
||||
Seitennavigation nicht mitschicken. Die Oberflaeche holt hier also erst die
|
||||
Adresse und leitet dann selbst dorthin weiter.
|
||||
|
||||
Damit braucht die Verknuepfung weder eine bestaetigte E-Mail-Adresse noch
|
||||
einen Abgleich ueber den Benutzernamen – beides waere eine Uebernahmeflaeche.
|
||||
"""
|
||||
provider = _provider_oder_404(slug)
|
||||
|
||||
schon_da = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.provider == provider.slug, UserIdentity.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if schon_da is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Dein Konto ist bereits mit '{provider.display_name}' verknüpft",
|
||||
)
|
||||
|
||||
oidc_state.aufraeumen(db)
|
||||
zettel = oidc_state.anlegen(db, provider.slug, link_user_id=current_user.id)
|
||||
try:
|
||||
ziel = dienst.anmelde_url(
|
||||
provider,
|
||||
state=zettel.state,
|
||||
nonce=zettel.nonce,
|
||||
code_verifier=zettel.code_verifier,
|
||||
)
|
||||
except dienst.OidcFehler as fehler:
|
||||
logger.warning("SSO '%s': Verknuepfung nicht startbar: %s", slug, fehler)
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(fehler)) from fehler
|
||||
|
||||
return OidcLinkStart(authorization_url=ziel)
|
||||
|
||||
|
||||
# response_model=None ist noetig, weil "from __future__ import annotations"
|
||||
# aus "-> None" den Typ NoneType macht - den FastAPI sonst als Antwortkoerper
|
||||
# deutet und mit 204 nicht vereinbar ist.
|
||||
@router.delete("/{slug}/link", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
def verknuepfung_loesen(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
"""Loest die Verknuepfung wieder.
|
||||
|
||||
Der Anbieter muss dafuer NICHT mehr eingerichtet sein – sonst liesse sich
|
||||
eine Altlast nach dem Abschalten von SSO nicht mehr entfernen.
|
||||
"""
|
||||
try:
|
||||
dienst.identitaet_loesen(db, current_user, slug)
|
||||
except dienst.OidcFehler as fehler:
|
||||
# "letzte Anmeldemoeglichkeit" ist kein Serverfehler, sondern eine
|
||||
# bewusste Weigerung – der Text erklaert den Ausweg.
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -10,6 +11,30 @@ from ..security import hash_password
|
||||
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
def _email_normalisiert(roh: str | None) -> str | None:
|
||||
"""Leereingabe heisst „keine Adresse"; gespeichert wird immer kleingeschrieben.
|
||||
|
||||
Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal
|
||||
uebernimmt – deshalb darf sie nicht zweimal vergeben sein.
|
||||
"""
|
||||
if roh is None:
|
||||
return None
|
||||
sauber = roh.strip().lower()
|
||||
return sauber or None
|
||||
|
||||
|
||||
def _email_pruefen(db: Session, email: str | None, ausser_id: int | None = None) -> None:
|
||||
if email is None:
|
||||
return
|
||||
frage = db.query(User).filter(func.lower(User.email) == email)
|
||||
if ausser_id is not None:
|
||||
frage = frage.filter(User.id != ausser_id)
|
||||
if frage.first() is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
def list_users(db: Session = Depends(get_db)) -> list[User]:
|
||||
return db.query(User).order_by(User.username).all()
|
||||
@@ -19,9 +44,12 @@ def list_users(db: Session = Depends(get_db)) -> list[User]:
|
||||
def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
if db.query(User).filter(User.username == payload.username).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
|
||||
email = _email_normalisiert(payload.email)
|
||||
_email_pruefen(db, email)
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
email=email,
|
||||
role=payload.role,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -39,6 +67,10 @@ def update_user(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
if payload.password is not None:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
if payload.email is not None:
|
||||
email = _email_normalisiert(payload.email)
|
||||
_email_pruefen(db, email, ausser_id=user.id)
|
||||
user.email = email
|
||||
if payload.role is not None:
|
||||
user.role = payload.role
|
||||
db.commit()
|
||||
|
||||
@@ -93,6 +93,7 @@ class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
username: str
|
||||
email: str | None = None
|
||||
role: Role
|
||||
created_at: datetime
|
||||
|
||||
@@ -100,14 +101,69 @@ class UserOut(BaseModel):
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=4, max_length=255)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
role: Role = Role.user
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
password: str | None = Field(default=None, min_length=4, max_length=255)
|
||||
# Leerer String heisst „Adresse entfernen" – deshalb hier nicht min_length.
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
role: Role | None = None
|
||||
|
||||
|
||||
# ---- Single Sign-on (OpenID Connect) ----
|
||||
class OidcProviderOut(BaseModel):
|
||||
"""Was ein Client braucht, um den Anmeldeknopf zu zeigen und den Flow zu starten.
|
||||
|
||||
Bewusst oeffentlich abrufbar: die Web-Oberflaeche kennt keine Build-Variablen,
|
||||
und die iOS-App muss die Angaben je Serverprofil vom jeweiligen Server lernen.
|
||||
Ein Client-Secret steht hier nie drin.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
discovery_url: str
|
||||
# Client-ID fuer die App (oeffentlicher Client, PKCE). Leer, wenn fuer diesen
|
||||
# Anbieter kein nativer Client eingerichtet ist.
|
||||
native_client_id: str | None = None
|
||||
native_redirect_uri: str
|
||||
# Scopes fuer den App-Weg (inkl. offline_access) – der Browser-Weg wird vom
|
||||
# Server selbst gebaut und braucht diese Angabe nicht.
|
||||
scopes: str
|
||||
|
||||
|
||||
class OidcIdentityOut(BaseModel):
|
||||
"""Eine bestehende Verknuepfung des angemeldeten Kontos."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
provider: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class OidcLinkStart(BaseModel):
|
||||
"""Adresse, zu der die Oberflaeche fuer die Verknuepfung weiterleiten soll.
|
||||
|
||||
Der Start laeuft ueber POST und nicht als einfacher Link, weil die
|
||||
Berechtigung aus dem Bearer-Token der laufenden Sitzung kommt – und das
|
||||
laesst sich bei einer normalen Seitennavigation nicht mitschicken.
|
||||
"""
|
||||
|
||||
authorization_url: str
|
||||
|
||||
|
||||
class OidcNativeLogin(BaseModel):
|
||||
"""ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird."""
|
||||
|
||||
id_token: str = Field(min_length=1)
|
||||
# Die nonce, die die App in ihrer Anfrage verwendet hat. AppAuth prueft sie
|
||||
# zwar selbst, aber nur der Server-Vergleich schliesst aus, dass ein anderswo
|
||||
# abgegriffenes ID-Token desselben Clients hier eingereicht wird.
|
||||
nonce: str | None = None
|
||||
|
||||
|
||||
# ---- Groups ----
|
||||
class LocationMinStockIn(BaseModel):
|
||||
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
|
||||
|
||||
@@ -18,7 +18,12 @@ def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
def verify_password(password: str, password_hash: str | None) -> bool:
|
||||
# Konten, die es nur ueber Single Sign-on gibt, haben gar keinen Hash. Ohne
|
||||
# diesen Riegel liefe None.encode() in einen AttributeError – und der Login
|
||||
# antwortete mit 500 statt mit einem ehrlichen 401.
|
||||
if not password_hash:
|
||||
return False
|
||||
try:
|
||||
pw = password.encode("utf-8")[:_BCRYPT_MAX_BYTES]
|
||||
return bcrypt.checkpw(pw, password_hash.encode("utf-8"))
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""OpenID Connect: Discovery, Token-Pruefung und Zuordnung zu Vorrania-Konten.
|
||||
|
||||
Der Protokoll-Teil laeuft ueber Authlib – Signaturpruefung, PKCE und
|
||||
Code-Tausch sind nichts, was man selbst schreiben sollte. Bewusst die
|
||||
SYNCHRONE Variante von Authlib: alle Routen dieses Projekts sind normale
|
||||
``def``-Funktionen im Threadpool, die Datenbank-Sitzung ist synchron, und
|
||||
ausgehendes HTTP laeuft schon in ``off.py`` ueber ein synchrones ``httpx``.
|
||||
|
||||
:func:`benutzer_aus_claims` ist absichtlich frei von HTTP: die gesamte
|
||||
Zuordnungs- und Anlage-Logik laesst sich damit mit einer Datenbank-Sitzung und
|
||||
einem selbstgebauten Claims-Wörterbuch pruefen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from authlib.integrations.httpx_client import OAuth2Client
|
||||
from authlib.jose import JsonWebKey, jwt
|
||||
from authlib.jose.errors import JoseError
|
||||
from authlib.oidc.core import CodeIDToken
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Role, User, UserIdentity
|
||||
from ..oidc_config import OidcProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 10.0
|
||||
# Discovery-Dokument und Schluessel aendern sich selten; eine Stunde Vorrat spart
|
||||
# bei jeder Anmeldung zwei Abfragen beim Anbieter.
|
||||
CACHE_SEKUNDEN = 3600.0
|
||||
# Etwas Spielraum fuer auseinanderlaufende Uhren zwischen Server und Anbieter.
|
||||
UHREN_SPIELRAUM = 60
|
||||
|
||||
|
||||
class OidcFehler(Exception):
|
||||
"""Anmeldung ueber den Anbieter ist gescheitert.
|
||||
|
||||
``schluessel`` landet als Kennung in der Weiterleitung zur Web-Oberflaeche,
|
||||
``args[0]`` ist der Klartext fuer Protokoll und API-Antwort.
|
||||
"""
|
||||
|
||||
def __init__(self, schluessel: str, meldung: str) -> None:
|
||||
super().__init__(meldung)
|
||||
self.schluessel = schluessel
|
||||
|
||||
|
||||
_metadaten_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_jwks_cache: dict[str, tuple[float, Any]] = {}
|
||||
|
||||
|
||||
def cache_leeren() -> None:
|
||||
"""Nur fuer Tests und zum erzwungenen Neuladen."""
|
||||
_metadaten_cache.clear()
|
||||
_jwks_cache.clear()
|
||||
|
||||
|
||||
def _holen(url: str) -> dict[str, Any]:
|
||||
try:
|
||||
antwort = httpx.get(url, timeout=TIMEOUT_SECONDS, follow_redirects=True)
|
||||
antwort.raise_for_status()
|
||||
return antwort.json()
|
||||
except (httpx.HTTPError, ValueError) as fehler:
|
||||
# Anders als beim Produkt-Lookup darf das hier NICHT still zu None werden –
|
||||
# ein nicht erreichbarer Anbieter muss als Fehler sichtbar sein.
|
||||
raise OidcFehler(
|
||||
"anbieter_nicht_erreichbar",
|
||||
f"Anmeldeanbieter nicht erreichbar ({url}): {fehler}",
|
||||
) from fehler
|
||||
|
||||
|
||||
def metadaten(provider: OidcProvider, erzwingen: bool = False) -> dict[str, Any]:
|
||||
"""Discovery-Dokument des Anbieters (gepuffert)."""
|
||||
eintrag = _metadaten_cache.get(provider.slug)
|
||||
if not erzwingen and eintrag and eintrag[0] > time.monotonic():
|
||||
return eintrag[1]
|
||||
|
||||
daten = _holen(provider.discovery_url)
|
||||
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
if not daten.get(pflicht):
|
||||
raise OidcFehler(
|
||||
"discovery_unvollstaendig",
|
||||
f"Discovery-Dokument von '{provider.slug}' ohne '{pflicht}'.",
|
||||
)
|
||||
_metadaten_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, daten)
|
||||
return daten
|
||||
|
||||
|
||||
def _jwks(provider: OidcProvider, erzwingen: bool = False) -> Any:
|
||||
eintrag = _jwks_cache.get(provider.slug)
|
||||
if not erzwingen and eintrag and eintrag[0] > time.monotonic():
|
||||
return eintrag[1]
|
||||
|
||||
schluessel = JsonWebKey.import_key_set(_holen(metadaten(provider)["jwks_uri"]))
|
||||
_jwks_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, schluessel)
|
||||
return schluessel
|
||||
|
||||
|
||||
def _client(provider: OidcProvider, redirect_uri: str) -> OAuth2Client:
|
||||
return OAuth2Client(
|
||||
client_id=provider.client_id,
|
||||
# Die App ist ein oeffentlicher Client und schickt nie ein Secret; nur der
|
||||
# vertrauliche Web-Client hat eins.
|
||||
client_secret=provider.client_secret or None,
|
||||
scope=provider.scopes,
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge_method="S256",
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def anmelde_url(provider: OidcProvider, state: str, nonce: str, code_verifier: str) -> str:
|
||||
"""Adresse, zu der der Browser fuer die Anmeldung geschickt wird."""
|
||||
meta = metadaten(provider)
|
||||
with _client(provider, provider.redirect_uri) as client:
|
||||
url, _ = client.create_authorization_url(
|
||||
meta["authorization_endpoint"],
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def code_einloesen(provider: OidcProvider, code: str, code_verifier: str) -> dict[str, Any]:
|
||||
"""Tauscht den Autorisierungs-Code gegen Tokens (vertraulicher Web-Client)."""
|
||||
meta = metadaten(provider)
|
||||
try:
|
||||
with _client(provider, provider.redirect_uri) as client:
|
||||
return client.fetch_token(
|
||||
meta["token_endpoint"],
|
||||
grant_type="authorization_code",
|
||||
code=code,
|
||||
code_verifier=code_verifier,
|
||||
redirect_uri=provider.redirect_uri,
|
||||
)
|
||||
except OidcFehler:
|
||||
raise
|
||||
except Exception as fehler: # Authlib wirft je nach Ursache sehr Verschiedenes.
|
||||
raise OidcFehler(
|
||||
"code_tausch_fehlgeschlagen", f"Code-Tausch gescheitert: {fehler}"
|
||||
) from fehler
|
||||
|
||||
|
||||
def pruefe_id_token(
|
||||
provider: OidcProvider,
|
||||
id_token: str,
|
||||
nonce: str | None = None,
|
||||
erlaubte_audiences: tuple[str, ...] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prueft Signatur, ``iss``, ``aud``, ``exp`` (und ``nonce``, falls bekannt).
|
||||
|
||||
``erlaubte_audiences`` schraenkt die zulaessigen Empfaenger ein: der
|
||||
Web-Rueckweg akzeptiert nur die vertrauliche Client-ID, der App-Weg nur die
|
||||
der registrierten oeffentlichen Clients.
|
||||
"""
|
||||
meta = metadaten(provider)
|
||||
audiences = erlaubte_audiences or provider.erlaubte_audiences
|
||||
if not audiences:
|
||||
raise OidcFehler(
|
||||
"keine_audience", f"Fuer '{provider.slug}' ist keine Client-ID hinterlegt."
|
||||
)
|
||||
|
||||
optionen = {
|
||||
"iss": {"essential": True, "value": meta["issuer"]},
|
||||
"aud": {"essential": True, "values": list(audiences)},
|
||||
"exp": {"essential": True},
|
||||
"sub": {"essential": True},
|
||||
}
|
||||
params = {"nonce": nonce} if nonce else None
|
||||
|
||||
def _entschluesseln(schluessel: Any) -> Any:
|
||||
return jwt.decode(
|
||||
id_token,
|
||||
key=schluessel,
|
||||
claims_cls=CodeIDToken,
|
||||
claims_options=optionen,
|
||||
claims_params=params,
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
claims = _entschluesseln(_jwks(provider))
|
||||
except JoseError:
|
||||
# Kann ein frisch gedrehter Signaturschluessel sein – einmal neu laden.
|
||||
claims = _entschluesseln(_jwks(provider, erzwingen=True))
|
||||
claims.validate(leeway=UHREN_SPIELRAUM)
|
||||
except JoseError as fehler:
|
||||
raise OidcFehler("id_token_ungueltig", f"ID-Token abgelehnt: {fehler}") from fehler
|
||||
|
||||
# Doppelt gehalten: aud steht schon in den claims_options, aber ein falsch
|
||||
# adressiertes Token darf unter keinen Umstaenden durchrutschen.
|
||||
roh_aud = claims.get("aud")
|
||||
token_aud = set(roh_aud) if isinstance(roh_aud, list) else {roh_aud}
|
||||
if not token_aud & set(audiences):
|
||||
raise OidcFehler("id_token_ungueltig", "ID-Token ist fuer einen anderen Client bestimmt.")
|
||||
|
||||
return dict(claims)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zuordnung zu Vorrania-Konten (ohne HTTP – direkt testbar)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ERLAUBT_IM_NAMEN = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
def _jetzt() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _namensvorschlag(claims: dict[str, Any]) -> str:
|
||||
roh = (
|
||||
claims.get("preferred_username")
|
||||
or claims.get("nickname")
|
||||
or (claims.get("email") or "").split("@")[0]
|
||||
or claims.get("name")
|
||||
or ""
|
||||
)
|
||||
sauber = _ERLAUBT_IM_NAMEN.sub("", str(roh).strip().replace(" ", "."))[:64].strip("._-")
|
||||
return sauber or "sso-nutzer"
|
||||
|
||||
|
||||
def _freier_name(db: Session, vorschlag: str) -> str:
|
||||
"""Macht den Namen eindeutig, ohne je ein bestehendes Konto zu uebernehmen."""
|
||||
if db.query(User).filter(User.username == vorschlag).first() is None:
|
||||
return vorschlag
|
||||
stamm = vorschlag[:58].rstrip("._-") or "sso-nutzer"
|
||||
for n in range(2, 1000):
|
||||
kandidat = f"{stamm}-{n}"
|
||||
if db.query(User).filter(User.username == kandidat).first() is None:
|
||||
return kandidat
|
||||
raise OidcFehler("kein_freier_name", "Kein freier Benutzername zu finden.")
|
||||
|
||||
|
||||
def _email_aus_claims(claims: dict[str, Any]) -> str | None:
|
||||
roh = claims.get("email")
|
||||
if not roh or not isinstance(roh, str):
|
||||
return None
|
||||
return roh.strip().lower() or None
|
||||
|
||||
|
||||
def identitaet_verknuepfen(
|
||||
db: Session, nutzer: User, provider: OidcProvider, claims: dict[str, Any]
|
||||
) -> UserIdentity:
|
||||
"""Haengt eine Anmeldedienst-Kennung an ein bestehendes, angemeldetes Konto.
|
||||
|
||||
Der Weg fuer alle, deren Konto keine (oder eine andere) E-Mail-Adresse hat:
|
||||
Die Berechtigung kommt hier aus der laufenden Vorrania-Sitzung, nicht aus
|
||||
einem Claim – deshalb braucht es weder eine bestaetigte Adresse noch einen
|
||||
Abgleich ueber den Benutzernamen.
|
||||
"""
|
||||
subject = str(claims.get("sub") or "").strip()
|
||||
if not subject:
|
||||
raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.")
|
||||
|
||||
vorhanden = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.provider == provider.slug, UserIdentity.subject == subject)
|
||||
.first()
|
||||
)
|
||||
if vorhanden is not None:
|
||||
if vorhanden.user_id == nutzer.id:
|
||||
return vorhanden # schon verknuepft – nichts zu tun
|
||||
# Diese Kennung gehoert einem ANDEREN Vorrania-Konto. Umhaengen waere
|
||||
# eine Uebernahme, auch wenn hier gerade jemand angemeldet ist.
|
||||
raise OidcFehler(
|
||||
"kennung_fremd",
|
||||
"Diese Anmeldung ist bereits mit einem anderen Vorrania-Konto verknuepft.",
|
||||
)
|
||||
|
||||
schon_dabei = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.provider == provider.slug, UserIdentity.user_id == nutzer.id)
|
||||
.first()
|
||||
)
|
||||
if schon_dabei is not None:
|
||||
raise OidcFehler(
|
||||
"schon_verknuepft",
|
||||
f"Dieses Konto ist bereits mit '{provider.display_name}' verknuepft.",
|
||||
)
|
||||
|
||||
identitaet = UserIdentity(
|
||||
user_id=nutzer.id,
|
||||
provider=provider.slug,
|
||||
subject=subject,
|
||||
last_login_at=_jetzt(),
|
||||
)
|
||||
db.add(identitaet)
|
||||
|
||||
# Adresse nachtragen, solange sie frei ist – dann findet auch die
|
||||
# Erstverknuepfung anderer Konten spaeter das Richtige.
|
||||
email = _email_aus_claims(claims)
|
||||
if email and not nutzer.email:
|
||||
if db.query(User).filter(func.lower(User.email) == email).first() is None:
|
||||
nutzer.email = email
|
||||
|
||||
db.commit()
|
||||
db.refresh(identitaet)
|
||||
logger.info("SSO '%s': mit Konto '%s' verknuepft.", provider.slug, nutzer.username)
|
||||
return identitaet
|
||||
|
||||
|
||||
def identitaet_loesen(db: Session, nutzer: User, provider_slug: str) -> None:
|
||||
"""Entfernt eine Verknuepfung – aber nie die letzte Anmeldemoeglichkeit."""
|
||||
identitaet = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.provider == provider_slug, UserIdentity.user_id == nutzer.id)
|
||||
.first()
|
||||
)
|
||||
if identitaet is None:
|
||||
raise OidcFehler("nicht_verknuepft", "Diese Verknuepfung besteht nicht.")
|
||||
|
||||
uebrige = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.user_id == nutzer.id, UserIdentity.id != identitaet.id)
|
||||
.count()
|
||||
)
|
||||
if not nutzer.password_hash and uebrige == 0:
|
||||
# Ohne Passwort und ohne weitere Kennung waere das Konto danach von
|
||||
# niemandem mehr erreichbar – auch nicht vom Besitzer.
|
||||
raise OidcFehler(
|
||||
"letzte_anmeldung",
|
||||
"Das ist die einzige Anmeldemoeglichkeit dieses Kontos. Vergib zuerst "
|
||||
"ein Passwort oder verknuepfe einen weiteren Anmeldedienst.",
|
||||
)
|
||||
|
||||
db.delete(identitaet)
|
||||
db.commit()
|
||||
logger.info("SSO '%s': Verknuepfung von '%s' geloest.", provider_slug, nutzer.username)
|
||||
|
||||
|
||||
def benutzer_aus_claims(
|
||||
db: Session, provider: OidcProvider, claims: dict[str, Any]
|
||||
) -> User:
|
||||
"""Findet oder erzeugt das Vorrania-Konto zu einem geprueften ID-Token.
|
||||
|
||||
Reihenfolge:
|
||||
|
||||
1. Bekannte Identitaet (Anbieter + ``sub``) – der Normalfall ab dem zweiten Mal.
|
||||
2. Erstverknuepfung ueber die **bestaetigte** E-Mail-Adresse.
|
||||
3. Neues Konto, falls fuer den Anbieter erlaubt.
|
||||
"""
|
||||
subject = str(claims.get("sub") or "").strip()
|
||||
if not subject:
|
||||
raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.")
|
||||
|
||||
email = _email_aus_claims(claims)
|
||||
# Authentik liefert das als echtes bool; andere Anbieter schicken schon mal
|
||||
# den String "true".
|
||||
roh_bestaetigt = claims.get("email_verified")
|
||||
email_bestaetigt = roh_bestaetigt is True or str(roh_bestaetigt).lower() == "true"
|
||||
|
||||
# 1) Bereits verknuepft.
|
||||
identitaet = (
|
||||
db.query(UserIdentity)
|
||||
.filter(UserIdentity.provider == provider.slug, UserIdentity.subject == subject)
|
||||
.first()
|
||||
)
|
||||
if identitaet is not None:
|
||||
nutzer = db.get(User, identitaet.user_id)
|
||||
if nutzer is None:
|
||||
# Verwaiste Verknuepfung (Konto geloescht) – aufraeumen und neu entscheiden.
|
||||
db.delete(identitaet)
|
||||
db.commit()
|
||||
else:
|
||||
identitaet.last_login_at = _jetzt()
|
||||
# Adresse nachtragen, falls das Konto noch keine hat – aber nur, wenn
|
||||
# sie nicht schon einem anderen Konto gehoert (eindeutiger Index).
|
||||
if email and not nutzer.email:
|
||||
if db.query(User).filter(func.lower(User.email) == email).first() is None:
|
||||
nutzer.email = email
|
||||
db.commit()
|
||||
db.refresh(nutzer)
|
||||
return nutzer
|
||||
|
||||
# 2) Erstverknuepfung ueber die E-Mail.
|
||||
if email and (email_bestaetigt or not provider.require_verified_email):
|
||||
treffer = db.query(User).filter(func.lower(User.email) == email).first()
|
||||
if treffer is not None:
|
||||
belegt = (
|
||||
db.query(UserIdentity)
|
||||
.filter(
|
||||
UserIdentity.provider == provider.slug,
|
||||
UserIdentity.user_id == treffer.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if belegt is not None:
|
||||
# Dasselbe Konto haengt schon an einer ANDEREN Kennung dieses
|
||||
# Anbieters. Stillschweigend umhaengen waere eine Uebernahme.
|
||||
raise OidcFehler(
|
||||
"konto_bereits_verknuepft",
|
||||
f"Das Konto '{treffer.username}' ist bereits mit einer anderen "
|
||||
f"Kennung bei '{provider.slug}' verknuepft.",
|
||||
)
|
||||
db.add(
|
||||
UserIdentity(
|
||||
user_id=treffer.id,
|
||||
provider=provider.slug,
|
||||
subject=subject,
|
||||
last_login_at=_jetzt(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(treffer)
|
||||
return treffer
|
||||
elif email and not email_bestaetigt:
|
||||
logger.info(
|
||||
"SSO '%s': E-Mail '%s' ist beim Anbieter nicht bestaetigt – keine "
|
||||
"Verknuepfung mit einem bestehenden Konto.",
|
||||
provider.slug,
|
||||
email,
|
||||
)
|
||||
|
||||
# 3) Neu anlegen, falls erlaubt.
|
||||
if not provider.auto_create:
|
||||
raise OidcFehler(
|
||||
"kein_konto",
|
||||
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss "
|
||||
"es zuerst anlegen und die E-Mail-Adresse hinterlegen.",
|
||||
)
|
||||
|
||||
# Die Adresse ist eindeutig indiziert. Sie kann hier trotzdem schon vergeben
|
||||
# sein – naemlich wenn Schritt 2 sie wegen fehlender Bestaetigung NICHT zum
|
||||
# Verknuepfen benutzen durfte. Dann bleibt das neue Konto ohne Adresse, statt
|
||||
# den Login an einem Datenbankfehler scheitern zu lassen.
|
||||
email_frei = email is not None and (
|
||||
db.query(User).filter(func.lower(User.email) == email).first() is None
|
||||
)
|
||||
if email and not email_frei:
|
||||
logger.info(
|
||||
"SSO '%s': Adresse '%s' ist schon vergeben – neues Konto bleibt ohne Adresse.",
|
||||
provider.slug,
|
||||
email,
|
||||
)
|
||||
|
||||
# Rolle kommt NIE aus dem Anbieter: sonst genuegte ein passend gesetzter Claim,
|
||||
# um sich Administratorrechte zu geben.
|
||||
nutzer = User(
|
||||
username=_freier_name(db, _namensvorschlag(claims)),
|
||||
password_hash=None,
|
||||
email=email if email_frei else None,
|
||||
role=Role.user,
|
||||
)
|
||||
db.add(nutzer)
|
||||
db.flush()
|
||||
db.add(
|
||||
UserIdentity(
|
||||
user_id=nutzer.id,
|
||||
provider=provider.slug,
|
||||
subject=subject,
|
||||
last_login_at=_jetzt(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(nutzer)
|
||||
logger.info("SSO '%s': neues Konto '%s' angelegt.", provider.slug, nutzer.username)
|
||||
return nutzer
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Kurzlebige Merkzettel fuer den Browser-Anmeldevorgang.
|
||||
|
||||
``state``, ``nonce`` und der PKCE-Verifier muessen den Ausflug zum Anmeldedienst
|
||||
ueberdauern. Sie liegen dafuer in der Datenbank statt in einem Cookie – warum,
|
||||
steht bei :class:`~app.models.OidcLoginState`.
|
||||
|
||||
Reine Datenbank-Funktionen, damit sie sich mit der vorhandenen ``db``-Fixture
|
||||
pruefen lassen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import OidcLoginState
|
||||
|
||||
# Genug fuer eine Anmeldung samt Zwei-Faktor-Eingabe, kurz genug, dass ein
|
||||
# liegengebliebener Zettel nicht ewig gilt.
|
||||
GUELTIG = timedelta(minutes=15)
|
||||
|
||||
|
||||
def _jetzt() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _mit_zeitzone(wert: datetime) -> datetime:
|
||||
# SQLite gibt Zeitstempel ohne Zonenangabe zurueck; ein Vergleich mit einem
|
||||
# bewussten Zeitpunkt wuerfe sonst einen TypeError.
|
||||
return wert if wert.tzinfo is not None else wert.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def anlegen(
|
||||
db: Session, provider: str, link_user_id: int | None = None
|
||||
) -> OidcLoginState:
|
||||
"""Erzeugt state, nonce und PKCE-Verifier und merkt sie vor.
|
||||
|
||||
``link_user_id`` macht daraus einen Verknuepfungs- statt Anmeldevorgang.
|
||||
"""
|
||||
zettel = OidcLoginState(
|
||||
state=secrets.token_urlsafe(32),
|
||||
provider=provider,
|
||||
nonce=secrets.token_urlsafe(24),
|
||||
# RFC 7636 erlaubt 43-128 Zeichen; token_urlsafe(64) liefert 86.
|
||||
code_verifier=secrets.token_urlsafe(64),
|
||||
link_user_id=link_user_id,
|
||||
created_at=_jetzt(),
|
||||
)
|
||||
db.add(zettel)
|
||||
db.commit()
|
||||
return zettel
|
||||
|
||||
|
||||
def einloesen(db: Session, state: str, jetzt: datetime | None = None) -> OidcLoginState | None:
|
||||
"""Holt den Zettel und entfernt ihn dabei – er gilt genau einmal.
|
||||
|
||||
Damit ist ein zweiter Rueckweg mit demselben ``state`` wirkungslos.
|
||||
"""
|
||||
zettel = db.get(OidcLoginState, state)
|
||||
if zettel is None:
|
||||
return None
|
||||
|
||||
# Vor dem Ablauf-Vergleich loeschen: auch ein abgelaufener Zettel ist
|
||||
# verbraucht und soll nicht liegenbleiben.
|
||||
db.delete(zettel)
|
||||
db.commit()
|
||||
|
||||
if _mit_zeitzone(zettel.created_at) + GUELTIG < (jetzt or _jetzt()):
|
||||
return None
|
||||
return zettel
|
||||
|
||||
|
||||
def aufraeumen(db: Session, jetzt: datetime | None = None) -> int:
|
||||
"""Entfernt abgelaufene Zettel (abgebrochene Anmeldungen)."""
|
||||
grenze = (jetzt or _jetzt()) - GUELTIG
|
||||
alte = [z for z in db.query(OidcLoginState).all() if _mit_zeitzone(z.created_at) < grenze]
|
||||
for zettel in alte:
|
||||
db.delete(zettel)
|
||||
if alte:
|
||||
db.commit()
|
||||
return len(alte)
|
||||
@@ -6,6 +6,9 @@ pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
# Single Sign-on (OpenID Connect): Discovery, PKCE, Code-Tausch und
|
||||
# ID-Token-Pruefung gegen den JWKS des Anbieters.
|
||||
Authlib==1.4.1
|
||||
python-multipart==0.0.20
|
||||
httpx==0.28.1
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Single Sign-on: Konfiguration lesen und Konten zuordnen.
|
||||
|
||||
Beides laeuft ohne HTTP – die Anbieter-Konfiguration ist eine reine Funktion
|
||||
ueber einem Umgebungs-Woerterbuch, und die Zuordnung bekommt ein fertiges
|
||||
Claims-Woerterbuch, wie es nach der Token-Pruefung vorliegt.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import Role, User, UserIdentity
|
||||
from app.oidc_config import OidcProvider, provider_aus_umgebung
|
||||
from app.routers.auth import login
|
||||
from app.services import oidc_state
|
||||
from app.security import hash_password, verify_password
|
||||
from app.services.oidc import (
|
||||
OidcFehler,
|
||||
benutzer_aus_claims,
|
||||
identitaet_loesen,
|
||||
identitaet_verknuepfen,
|
||||
)
|
||||
|
||||
BASIS = "https://vorrania.example.com"
|
||||
|
||||
VOLLE_UMGEBUNG = {
|
||||
"OIDC_PROVIDERS": "authentik",
|
||||
"OIDC_AUTHENTIK_DISPLAY_NAME": "Authentik",
|
||||
"OIDC_AUTHENTIK_DISCOVERY_URL": "https://idp.example.com/application/o/v/.well-known/openid-configuration",
|
||||
"OIDC_AUTHENTIK_CLIENT_ID": "web-client",
|
||||
"OIDC_AUTHENTIK_CLIENT_SECRET": "geheim",
|
||||
"OIDC_AUTHENTIK_NATIVE_CLIENT_IDS": "ios-client, ipad-client",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> OidcProvider:
|
||||
return OidcProvider(
|
||||
slug="authentik",
|
||||
display_name="Authentik",
|
||||
discovery_url="https://idp.example.com/.well-known/openid-configuration",
|
||||
client_id="web-client",
|
||||
client_secret="geheim",
|
||||
native_client_ids=("ios-client",),
|
||||
redirect_uri=f"{BASIS}/api/auth/oidc/authentik/callback",
|
||||
)
|
||||
|
||||
|
||||
def claims(**felder) -> dict:
|
||||
grund = {"sub": "abc-123", "email": "eva@example.com", "email_verified": True}
|
||||
grund.update(felder)
|
||||
return grund
|
||||
|
||||
|
||||
# ---- Konfiguration --------------------------------------------------------
|
||||
|
||||
|
||||
def test_ohne_konfiguration_kein_anbieter():
|
||||
"""Ohne OIDC_PROVIDERS bleibt alles beim Passwort-Login.
|
||||
|
||||
Wichtig, weil update.sh bestehende .env-Dateien nicht nachtraegt – ein alter
|
||||
Bestand darf durch das Update nicht ploetzlich beim Start scheitern.
|
||||
"""
|
||||
assert provider_aus_umgebung({}, BASIS) == []
|
||||
|
||||
|
||||
def test_anbieter_wird_vollstaendig_gelesen():
|
||||
(p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS)
|
||||
assert p.slug == "authentik"
|
||||
assert p.display_name == "Authentik"
|
||||
assert p.client_id == "web-client"
|
||||
assert p.client_secret == "geheim"
|
||||
assert p.native_client_ids == ("ios-client", "ipad-client")
|
||||
# Das /api-Praefix entsteht erst in nginx und muss deshalb hier drinstehen.
|
||||
assert p.redirect_uri == f"{BASIS}/api/auth/oidc/authentik/callback"
|
||||
# Der Browser-Weg braucht kein Refresh-Token – offline_access dort anzufragen
|
||||
# loeste nur einen ueberfluessigen Zustimmungsdialog aus.
|
||||
assert p.scopes == "openid profile email"
|
||||
# Die App dagegen erneuert selbst und braucht es zwingend: ohne diesen Scope
|
||||
# liefert Authentik seit 2024.2 gar kein Refresh-Token.
|
||||
assert "offline_access" in p.native_scopes
|
||||
assert p.erlaubte_audiences == ("web-client", "ios-client", "ipad-client")
|
||||
|
||||
|
||||
def test_zweiter_anbieter_ist_reine_konfiguration():
|
||||
"""Ein weiterer Anbieter darf keine Codeaenderung brauchen."""
|
||||
umgebung = dict(VOLLE_UMGEBUNG)
|
||||
umgebung["OIDC_PROVIDERS"] = "authentik,keycloak"
|
||||
umgebung["OIDC_KEYCLOAK_DISCOVERY_URL"] = "https://kc.example.com/.well-known/openid-configuration"
|
||||
umgebung["OIDC_KEYCLOAK_CLIENT_ID"] = "kc-web"
|
||||
|
||||
anbieter = provider_aus_umgebung(umgebung, BASIS)
|
||||
assert [p.slug for p in anbieter] == ["authentik", "keycloak"]
|
||||
assert anbieter[1].display_name == "Keycloak" # Vorgabe aus dem Kuerzel
|
||||
|
||||
|
||||
def test_unvollstaendiger_anbieter_wird_uebersprungen():
|
||||
"""Ein Tippfehler in der .env darf den Serverstart nicht verhindern."""
|
||||
umgebung = {"OIDC_PROVIDERS": "authentik"} # weder Discovery noch Client-ID
|
||||
assert provider_aus_umgebung(umgebung, BASIS) == []
|
||||
|
||||
|
||||
def test_ohne_basisadresse_kein_anbieter():
|
||||
"""Ohne PUBLIC_BASE_URL laesst sich die Rueckleit-Adresse nicht bilden."""
|
||||
assert provider_aus_umgebung(VOLLE_UMGEBUNG, "") == []
|
||||
|
||||
|
||||
def test_ausdrueckliche_rueckleitadresse_ersetzt_die_basisadresse():
|
||||
umgebung = dict(VOLLE_UMGEBUNG)
|
||||
umgebung["OIDC_AUTHENTIK_REDIRECT_URI"] = "https://anders.example.com/api/auth/oidc/authentik/callback"
|
||||
(p,) = provider_aus_umgebung(umgebung, "")
|
||||
assert p.redirect_uri == "https://anders.example.com/api/auth/oidc/authentik/callback"
|
||||
|
||||
|
||||
# ---- Zuordnung ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bekannte_identitaet_meldet_denselben_nutzer_an(db, provider):
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add(eva)
|
||||
db.flush()
|
||||
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123"))
|
||||
db.commit()
|
||||
|
||||
gefunden = benutzer_aus_claims(db, provider, claims())
|
||||
|
||||
assert gefunden.id == eva.id
|
||||
assert gefunden.role == Role.admin # Rolle bleibt, was der Admin gesetzt hat
|
||||
assert db.query(User).count() == 1
|
||||
|
||||
|
||||
def test_erstverknuepfung_ueber_bestaetigte_email(db, provider):
|
||||
"""Der Regelfall beim Umstieg: Admin traegt seine Adresse ein, meldet sich an."""
|
||||
eva = User(
|
||||
username="eva",
|
||||
password_hash=hash_password("geheim123"),
|
||||
email="eva@example.com",
|
||||
role=Role.admin,
|
||||
)
|
||||
db.add(eva)
|
||||
db.commit()
|
||||
|
||||
gefunden = benutzer_aus_claims(db, provider, claims())
|
||||
|
||||
assert gefunden.id == eva.id
|
||||
assert gefunden.role == Role.admin
|
||||
assert db.query(User).count() == 1
|
||||
# Ab jetzt zaehlt die sub, nicht mehr die Adresse.
|
||||
identitaet = db.query(UserIdentity).one()
|
||||
assert (identitaet.provider, identitaet.subject) == ("authentik", "abc-123")
|
||||
|
||||
|
||||
def test_unbestaetigte_email_verknuepft_nicht(db, provider):
|
||||
"""Sonst genuegt eine frei gewaehlte Adresse im IdP zur Kontouebernahme."""
|
||||
db.add(
|
||||
User(
|
||||
username="eva",
|
||||
password_hash=hash_password("geheim123"),
|
||||
email="eva@example.com",
|
||||
role=Role.admin,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
gefunden = benutzer_aus_claims(db, provider, claims(email_verified=False))
|
||||
|
||||
assert gefunden.username != "eva"
|
||||
assert gefunden.role == Role.user
|
||||
assert db.query(User).count() == 2
|
||||
# Die Adresse gehoert weiterhin allein dem Bestandskonto.
|
||||
assert gefunden.email is None
|
||||
|
||||
|
||||
def test_ohne_auto_anlage_wird_abgewiesen(db, provider):
|
||||
from dataclasses import replace
|
||||
|
||||
ohne = replace(provider, auto_create=False)
|
||||
|
||||
with pytest.raises(OidcFehler) as fehler:
|
||||
benutzer_aus_claims(db, ohne, claims())
|
||||
|
||||
assert fehler.value.schluessel == "kein_konto"
|
||||
assert db.query(User).count() == 0
|
||||
|
||||
|
||||
def test_auto_anlage_erzeugt_konto_ohne_passwort(db, provider):
|
||||
neu = benutzer_aus_claims(db, provider, claims(preferred_username="eva.mueller"))
|
||||
|
||||
assert neu.username == "eva.mueller"
|
||||
assert neu.email == "eva@example.com"
|
||||
# Rolle kommt NIE aus dem Anbieter.
|
||||
assert neu.role == Role.user
|
||||
# Reines SSO-Konto: kein Passwort, also auch kein Passwort-Login moeglich.
|
||||
assert neu.password_hash is None
|
||||
|
||||
|
||||
def test_namenskollision_uebernimmt_kein_fremdes_konto(db, provider):
|
||||
"""Ein passend gewaehlter preferred_username darf kein Konto kapern."""
|
||||
fremd = User(username="admin", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add(fremd)
|
||||
db.commit()
|
||||
|
||||
neu = benutzer_aus_claims(db, provider, claims(preferred_username="admin"))
|
||||
|
||||
assert neu.id != fremd.id
|
||||
assert neu.username == "admin-2"
|
||||
assert neu.role == Role.user
|
||||
# Das bestehende Admin-Konto bleibt unangetastet.
|
||||
db.refresh(fremd)
|
||||
assert fremd.role == Role.admin
|
||||
assert fremd.password_hash is not None
|
||||
|
||||
|
||||
def test_zweite_kennung_auf_verknuepftes_konto_wird_abgelehnt(db, provider):
|
||||
eva = User(username="eva", password_hash=None, email="eva@example.com", role=Role.user)
|
||||
db.add(eva)
|
||||
db.flush()
|
||||
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="alt-999"))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(OidcFehler) as fehler:
|
||||
benutzer_aus_claims(db, provider, claims(sub="neu-111"))
|
||||
|
||||
assert fehler.value.schluessel == "konto_bereits_verknuepft"
|
||||
|
||||
|
||||
def test_sso_konto_ist_per_passwort_nicht_anmeldbar():
|
||||
"""Ohne Hash muss die Pruefung schlicht False sagen – kein AttributeError.
|
||||
|
||||
Sonst antwortet POST /auth/login mit 500 statt mit einem ehrlichen 401.
|
||||
"""
|
||||
assert verify_password("irgendwas", None) is False
|
||||
assert verify_password("irgendwas", "") is False
|
||||
|
||||
|
||||
# ---- Merkzettel fuer den Browser-Weg ---------------------------------------
|
||||
|
||||
|
||||
def test_zettel_gilt_genau_einmal(db):
|
||||
"""Ein zweiter Rueckweg mit demselben state muss ins Leere laufen."""
|
||||
zettel = oidc_state.anlegen(db, "authentik")
|
||||
|
||||
erster = oidc_state.einloesen(db, zettel.state)
|
||||
assert erster is not None
|
||||
assert erster.provider == "authentik"
|
||||
assert len(erster.code_verifier) >= 43 # RFC 7636
|
||||
|
||||
assert oidc_state.einloesen(db, zettel.state) is None
|
||||
|
||||
|
||||
def test_abgelaufener_zettel_wird_abgelehnt(db):
|
||||
zettel = oidc_state.anlegen(db, "authentik")
|
||||
spaeter = datetime.now(timezone.utc) + oidc_state.GUELTIG + timedelta(seconds=1)
|
||||
|
||||
assert oidc_state.einloesen(db, zettel.state, jetzt=spaeter) is None
|
||||
|
||||
|
||||
def test_unbekannter_zettel_wird_abgelehnt(db):
|
||||
assert oidc_state.einloesen(db, "nie-vergeben") is None
|
||||
|
||||
|
||||
def test_aufraeumen_entfernt_nur_alte_zettel(db):
|
||||
alt = oidc_state.anlegen(db, "authentik")
|
||||
neu = oidc_state.anlegen(db, "authentik")
|
||||
# Den einen kuenstlich altern lassen.
|
||||
alt.created_at = datetime.now(timezone.utc) - oidc_state.GUELTIG - timedelta(minutes=1)
|
||||
db.commit()
|
||||
|
||||
assert oidc_state.aufraeumen(db) == 1
|
||||
assert oidc_state.einloesen(db, neu.state) is not None
|
||||
|
||||
|
||||
def test_zettel_tragen_unterschiedliche_werte(db):
|
||||
a = oidc_state.anlegen(db, "authentik")
|
||||
b = oidc_state.anlegen(db, "authentik")
|
||||
assert a.state != b.state
|
||||
assert a.nonce != b.nonce
|
||||
assert a.code_verifier != b.code_verifier
|
||||
|
||||
|
||||
# ---- Passwort-Login gegen ein reines SSO-Konto -----------------------------
|
||||
|
||||
|
||||
def test_passwort_login_gegen_sso_konto_gibt_401(db):
|
||||
"""Kein 500: das Konto hat keinen Hash, das ist ein Anmeldefehler."""
|
||||
db.add(User(username="eva", password_hash=None, role=Role.user))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
login(form=SimpleNamespace(username="eva", password="beliebig"), db=db)
|
||||
|
||||
assert fehler.value.status_code == 401
|
||||
|
||||
|
||||
def test_sehr_langer_name_passt_in_die_spalte(db, provider):
|
||||
"""username ist String(64) – ein langer preferred_username darf nicht sprengen."""
|
||||
neu = benutzer_aus_claims(db, provider, claims(preferred_username="e" * 200))
|
||||
assert len(neu.username) <= 64
|
||||
|
||||
|
||||
# ---- Verknuepfung eines bestehenden Kontos ---------------------------------
|
||||
|
||||
|
||||
def test_verknuepfung_haengt_kennung_an_das_angemeldete_konto(db, provider):
|
||||
"""Der Weg fuer Konten ohne (passende) E-Mail: Berechtigung aus der Sitzung."""
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add(eva)
|
||||
db.commit()
|
||||
|
||||
identitaet = identitaet_verknuepfen(db, eva, provider, claims(email=None))
|
||||
|
||||
assert identitaet.user_id == eva.id
|
||||
assert identitaet.subject == "abc-123"
|
||||
# Ab jetzt meldet dieselbe Kennung genau dieses Konto an – mit seiner Rolle.
|
||||
gefunden = benutzer_aus_claims(db, provider, claims())
|
||||
assert gefunden.id == eva.id
|
||||
assert gefunden.role == Role.admin
|
||||
|
||||
|
||||
def test_verknuepfung_braucht_keine_bestaetigte_email(db, provider):
|
||||
"""Anders als die Erstverknuepfung: hier buergt die laufende Sitzung."""
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add(eva)
|
||||
db.commit()
|
||||
|
||||
identitaet_verknuepfen(db, eva, provider, claims(email_verified=False))
|
||||
|
||||
assert db.query(UserIdentity).count() == 1
|
||||
|
||||
|
||||
def test_verknuepfung_zweimal_ist_folgenlos(db, provider):
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
|
||||
db.add(eva)
|
||||
db.commit()
|
||||
|
||||
erste = identitaet_verknuepfen(db, eva, provider, claims())
|
||||
zweite = identitaet_verknuepfen(db, eva, provider, claims())
|
||||
|
||||
assert erste.id == zweite.id
|
||||
assert db.query(UserIdentity).count() == 1
|
||||
|
||||
|
||||
def test_fremde_kennung_wird_nicht_umgehaengt(db, provider):
|
||||
"""Auch eine gueltige Sitzung darf keine fremde Kennung an sich ziehen."""
|
||||
otto = User(username="otto", password_hash=hash_password("geheim123"), role=Role.user)
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add_all([otto, eva])
|
||||
db.flush()
|
||||
db.add(UserIdentity(user_id=otto.id, provider="authentik", subject="abc-123"))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(OidcFehler) as fehler:
|
||||
identitaet_verknuepfen(db, eva, provider, claims())
|
||||
|
||||
assert fehler.value.schluessel == "kennung_fremd"
|
||||
assert db.query(UserIdentity).one().user_id == otto.id
|
||||
|
||||
|
||||
def test_zweite_kennung_desselben_dienstes_wird_abgelehnt(db, provider):
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
|
||||
db.add(eva)
|
||||
db.flush()
|
||||
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="alt-999"))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(OidcFehler) as fehler:
|
||||
identitaet_verknuepfen(db, eva, provider, claims(sub="neu-111"))
|
||||
|
||||
assert fehler.value.schluessel == "schon_verknuepft"
|
||||
|
||||
|
||||
def test_loesen_entfernt_die_verknuepfung(db, provider):
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
|
||||
db.add(eva)
|
||||
db.flush()
|
||||
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123"))
|
||||
db.commit()
|
||||
|
||||
identitaet_loesen(db, eva, "authentik")
|
||||
|
||||
assert db.query(UserIdentity).count() == 0
|
||||
|
||||
|
||||
def test_letzte_anmeldemoeglichkeit_bleibt_bestehen(db, provider):
|
||||
"""Ein reines SSO-Konto darf sich nicht selbst aussperren."""
|
||||
eva = User(username="eva", password_hash=None, role=Role.user)
|
||||
db.add(eva)
|
||||
db.flush()
|
||||
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123"))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(OidcFehler) as fehler:
|
||||
identitaet_loesen(db, eva, "authentik")
|
||||
|
||||
assert fehler.value.schluessel == "letzte_anmeldung"
|
||||
assert db.query(UserIdentity).count() == 1
|
||||
|
||||
|
||||
def test_zettel_merkt_sich_das_zu_verknuepfende_konto(db):
|
||||
"""Nur so weiss der Rueckweg, dass er verknuepfen und nicht anmelden soll."""
|
||||
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
|
||||
db.add(eva)
|
||||
db.commit()
|
||||
|
||||
zettel = oidc_state.anlegen(db, "authentik", link_user_id=eva.id)
|
||||
eingeloest = oidc_state.einloesen(db, zettel.state)
|
||||
|
||||
assert eingeloest.link_user_id == eva.id
|
||||
# Eine normale Anmeldung traegt das Feld nicht.
|
||||
assert oidc_state.einloesen(db, oidc_state.anlegen(db, "authentik").state).link_user_id is None
|
||||
Reference in New Issue
Block a user