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
+77 -1
View File
@@ -118,10 +118,86 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# Leer bei Konten, die es nur ueber Single Sign-on gibt. Ein solches Konto ist
# per Passwort schlicht nicht anmeldbar (siehe routers/auth.py).
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Nur fuer die Erstverknuepfung mit einem SSO-Konto. Bewusst nicht Pflicht:
# bestehende Installationen haben keine Adressen hinterlegt.
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
identities: Mapped[list[UserIdentity]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
__table_args__ = (
# Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal
# uebernimmt – zwei Konten mit derselben Adresse waeren also mehrdeutig.
# Gross-/Kleinschreibung darf dabei keinen Unterschied machen, und leere
# Adressen (der Normalfall bei Passwort-Konten) bleiben aussen vor.
Index(
"uq_users_email",
text("lower(email)"),
unique=True,
postgresql_where=text("email IS NOT NULL"),
sqlite_where=text("email IS NOT NULL"),
),
)
class OidcLoginState(Base):
"""Merkzettel zwischen „zum Anmeldedienst geschickt" und „zurueckgekommen".
Bewusst eine Tabelle und kein Sitzungs-Cookie. Dieses Backend kennt sonst
gar keine Cookies, und Starlettes CORS-Schicht wechselt bei
``allow_origins=["*"]`` **zusammen mit** ``allow_credentials=True`` auf die
gespiegelte Herkunft, sobald ein Request ueberhaupt ein Cookie mitbringt
(starlette/middleware/cors.py). Ohne Cookie entsteht diese Wechselwirkung
erst gar nicht – und mit der ``db``-Fixture laesst sich der Ablauf pruefen,
was bei einer Middleware ohne TestClient nicht ginge.
"""
__tablename__ = "oidc_login_states"
state: Mapped[str] = mapped_column(String(64), primary_key=True)
provider: Mapped[str] = mapped_column(String(64), nullable=False)
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
# Gesetzt, wenn der Vorgang eine VERKNUEPFUNG ist statt einer Anmeldung: Der
# Rueckweg haengt die Identitaet dann an genau dieses (bereits angemeldete)
# Konto, statt jemanden anzumelden. Die Berechtigung dafuer stammt aus der
# laufenden Vorrania-Sitzung, nicht aus der E-Mail-Adresse.
link_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class UserIdentity(Base):
"""Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter.
Eigene Tabelle statt zweier Spalten am Benutzer, damit ein Konto spaeter
Identitaeten bei MEHREREN Anbietern haben kann (Authentik heute, etwas
anderes morgen) – genau die Erweiterbarkeit, um die es beim OIDC-Umbau geht.
"""
__tablename__ = "user_identities"
__table_args__ = (UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# Das Kuerzel aus OIDC_PROVIDERS, z.B. "authentik".
provider: Mapped[str] = mapped_column(String(64), nullable=False)
# Die sub-Angabe des Anbieters – stabil, auch wenn sich die E-Mail aendert.
subject: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped[User] = relationship(back_populates="identities")
# Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht
# nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit