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>
101 lines
3.4 KiB
Python
101 lines
3.4 KiB
Python
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()
|