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
+77
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user