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