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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user