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>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""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
|