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:
Scarriffle
2026-09-09 17:28:24 +02:00
co-authored by Claude Opus 5
parent 01ae12ffea
commit 7690718731
18 changed files with 1633 additions and 16 deletions
@@ -0,0 +1,78 @@
"""Anmeldung ueber einen fremden Dienst (OpenID Connect).
Drei Aenderungen:
``user_identities`` welches Konto bei welchem Anbieter zu welchem Nutzer
gehoert. Eigene Tabelle, weil jemand mehrere haben kann.
``oidc_states`` begonnene Anmeldeversuche, bis der Nutzer zurueckkommt.
``users`` ein Passwort-Hash, der leer sein DARF – ein reiner
SSO-Nutzer hat nie eines gesetzt.
Bewusst KEINE E-Mail-Spalte am Nutzer: Ueber die E-Mail automatisch
zusammenzufuehren waere der bequeme Weg und der gefaehrliche. Wer beim
Anbieter eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes
Konto. Verknuepft wird ausdruecklich, im angemeldeten Zustand.
Revision ID: 0033
Revises: 0032
"""
import sqlalchemy as sa
from alembic import op
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_identities",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
# Eine Kennung gehoert zu genau EINEM Nutzer – sonst landete derselbe
# Anmeldende mal hier und mal dort.
sa.UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
)
op.create_index("ix_user_identities_user_id", "user_identities", ["user_id"])
op.create_table(
"oidc_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("state", sa.String(length=64), nullable=False, unique=True),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("nonce", sa.String(length=64), nullable=False),
sa.Column("code_verifier", sa.String(length=128), nullable=False),
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
sa.Column("link_user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
sa.Column("weiter", sa.String(length=255), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_oidc_states_state", "oidc_states", ["state"])
# password_hash darf jetzt leer sein. Das geht auf SQLite nur ueber einen
# Tabellen-Umbau; migrations/env.py schaltet die Fremdschluessel dafuer ab,
# sonst risse das implizite DELETE beim DROP saemtliche Daten des Nutzers
# mit (siehe tests/test_migrations.py).
with op.batch_alter_table("users") as batch:
batch.alter_column("password_hash", existing_type=sa.String(length=255),
nullable=True)
def downgrade() -> None:
with op.batch_alter_table("users") as batch:
batch.alter_column("password_hash", existing_type=sa.String(length=255),
nullable=False)
op.drop_index("ix_oidc_states_state", table_name="oidc_states")
op.drop_table("oidc_states")
op.drop_index("ix_user_identities_user_id", table_name="user_identities")
op.drop_table("user_identities")