feat: Anmelden ueber Authentik – zusaetzlich zu Benutzername und Passwort
Ein zweiter Weg herein, kein Ersatz. Wer weiter Passwort und Zwei-Faktor
benutzen will, merkt nichts: Ohne eingerichteten Anbieter sieht die
Login-Seite aus wie bisher.
DER WEG FUER BESTEHENDE KONTEN ist das Verknuepfen, nicht das Anmelden: einmal
wie gewohnt hereinkommen, unter "Mein Konto" auf "Mit Authentik verknuepfen",
fertig. Danach geht beides.
Was es ausdruecklich NICHT gibt, ist das automatische Zusammenfuehren ueber
die E-Mail. Es waere der bequeme Weg und der gefaehrliche: Wer beim Anbieter
eine fremde Adresse eintraegt, uebernaehme damit ein fremdes Konto – und die
E-Mail ist dort die Angabe, die sich am leichtesten aendern laesst. Deshalb
sagt der Nutzer selbst, wer er beim Anbieter ist, im angemeldeten Zustand.
Aus demselben Grund legt eine unbekannte Anmeldung standardmaessig KEIN Konto
an, sondern wird mit dem Hinweis abgewiesen, sich anzumelden und zu
verknuepfen. Sonst bekaeme genau der Mensch, der schon ein Konto hat, bei
seiner ersten SSO-Anmeldung ein zweites, leeres – und seine Daten laegen
weiter unter dem alten. Wer will, schaltet AUTO_CREATE ein.
Geprueft wird mit Authlib, nicht von Hand: Signatur gegen JWKS, Aussteller,
Empfaenger, Laufzeit und nonce. Dazu state gegen untergeschobene Rueckkehr und
PKCE, obwohl der Client vertraulich ist – es kostet nichts und der Code taugt
damit auch fuer einen oeffentlichen.
Ein zweiter Anbieter ist reine Konfiguration:
APP_OIDC_PROVIDERS=authentik,keycloak
Nebenbei zwei Stellen abgesichert, die es vorher nicht geben konnte: Ein Konto
ohne Passwort faellt beim Passwort-Login nicht mehr in argon2 (das haette
verraten, dass es diesen Benutzernamen gibt), und die letzte Verknuepfung
laesst sich nicht loesen, solange es kein Passwort gibt – sonst schliesst sich
der Nutzer aus seinem eigenen Konto aus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
01ae12ffea
commit
7690718731
+71
-1
@@ -116,7 +116,11 @@ 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 reinen SSO-Nutzern: Wer sich nur ueber einen fremden
|
||||
# Anmeldedienst anmeldet, hat hier nie ein Passwort gesetzt. NULL heisst
|
||||
# ausdruecklich "kein Passwort-Login", nicht "leeres Passwort" – die
|
||||
# Anmeldung prueft das, bevor sie ueberhaupt vergleicht.
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user)
|
||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
@@ -151,6 +155,72 @@ class User(Base):
|
||||
)
|
||||
|
||||
|
||||
class UserIdentity(Base):
|
||||
"""Ein Konto bei einem fremden Anmeldedienst, das zu diesem Nutzer gehoert.
|
||||
|
||||
Eigene Tabelle und nicht zwei Spalten am Nutzer: Jemand kann sich ueber
|
||||
mehrere Dienste anmelden, und die Kennung (``subject``) gilt immer nur
|
||||
beim jeweiligen Anbieter. Zwei Nutzer duerfen dieselbe Kennung haben,
|
||||
solange sie von verschiedenen Anbietern kommt.
|
||||
|
||||
``subject`` ist die ``sub``-Angabe aus dem ID-Token: die einzige, die der
|
||||
Anbieter garantiert stabil haelt. Die E-Mail steht nur zur Anzeige dabei –
|
||||
sie kann sich aendern, ohne dass es ein anderer Mensch wird.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_identities"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Eine Kennung gehoert zu genau EINEM Nutzer. Ohne diese Regel koennte
|
||||
# ein zweiter Nutzer dieselbe Kennung bekommen, und wer sich anmeldet,
|
||||
# landete mal hier und mal dort.
|
||||
UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
|
||||
)
|
||||
|
||||
|
||||
class OidcState(Base):
|
||||
"""Ein begonnener Anmeldeversuch, bis der Nutzer zurueckkommt.
|
||||
|
||||
Muss serverseitig liegen und nicht im Cookie: Der Nutzer verlaesst die
|
||||
Seite und kommt ueber eine fremde Adresse zurueck – bei ``SameSite=lax``
|
||||
kaeme ein Cookie zwar mit, aber ``code_verifier`` und ``nonce`` gehoeren
|
||||
ohnehin nicht in den Browser.
|
||||
|
||||
Wird beim Einloesen GELOESCHT, nicht nur als benutzt markiert: Ein Code
|
||||
darf genau einmal eingeloest werden.
|
||||
"""
|
||||
|
||||
__tablename__ = "oidc_states"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
state: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
# Gesetzt, wenn ein bereits angemeldeter Nutzer sein Konto VERKNUEPFEN will
|
||||
# statt sich anzumelden. Dann darf am Ende kein fremdes Konto entstehen.
|
||||
link_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# Wohin es in der Oberflaeche danach weitergeht.
|
||||
weiter: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user