Files
Vorrania/backend/app/routers/users.py
T
ScarriffleandClaude Opus 5 3d0ff54bf5 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>
2026-09-09 21:55:38 +02:00

101 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import require_admin
from ..models import Role, User
from ..schemas import UserCreate, UserOut, UserUpdate
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()
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
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)
db.commit()
db.refresh(user)
return user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(
user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
) -> User:
user = db.get(User, user_id)
if user is None:
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()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(require_admin),
) -> None:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
if user.id == current_admin.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Du kannst dich nicht selbst löschen")
# Verhindere das Löschen des letzten Admins.
if user.role == Role.admin:
admin_count = db.query(User).filter(User.role == Role.admin).count()
if admin_count <= 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Der letzte Administrator kann nicht gelöscht werden"
)
db.delete(user)
db.commit()