feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)
Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet enabled:false und der Login-Screen bleibt unveraendert. Backend - oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung. - oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit alg:none und Algorithmus-Confusion ausgeschlossen sind). - oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto. Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist. - routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange, /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment. - /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als Public Client selbst aus und tauscht das ID-Token; kein Secret im Request. - models.py: neue Tabelle oidc_identities, users.auth_source; Migration in main.py im dortigen try/except-Stil. Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer. Web - SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links, weil /start eine Top-Level-Navigation braucht. - consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und raeumt die Query per replaceState ab, bevor es awaited. - 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener Handoff eine Reload-Schleife. - i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen). - Service Worker laesst /api/auth/oidc/ unangetastet durch. 37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens, alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies, Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a30375aa63
commit
31baf4b18c
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
@@ -20,6 +22,9 @@ class User(Base):
|
||||
# When true, the user is hidden from sharing/group picker directories
|
||||
# (/users/directory). Admin user management (/users/) still shows them.
|
||||
directory_hidden = Column(Boolean, default=False, nullable=False)
|
||||
# 'local' (password login) or 'sso' (provisioned via OpenID Connect).
|
||||
# SSO users have a random, unusable password_hash — see oidc_identity.
|
||||
auth_source = Column(String(20), default="local")
|
||||
|
||||
caldav_accounts = relationship(
|
||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
@@ -39,6 +44,9 @@ class User(Base):
|
||||
homeassistant_accounts = relationship(
|
||||
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
oidc_identities = relationship(
|
||||
"OIDCIdentity", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
@@ -450,3 +458,33 @@ class GroupCalendar(Base):
|
||||
|
||||
group = relationship("Group", back_populates="group_calendar")
|
||||
calendar = relationship("LocalCalendar")
|
||||
|
||||
|
||||
class OIDCIdentity(Base):
|
||||
"""An external OpenID Connect identity linked to a Calendarr user.
|
||||
|
||||
A separate table rather than columns on ``users`` so one person can hold
|
||||
identities at several providers (the multi-provider requirement).
|
||||
|
||||
Keyed on ``(provider_key, subject)`` and NOT on ``(issuer, subject)``: if
|
||||
the web and mobile clients are registered as two Authentik *applications*
|
||||
they have different issuer URLs, which would otherwise fork one human into
|
||||
two accounts. ``issuer`` is kept for auditing.
|
||||
"""
|
||||
|
||||
__tablename__ = "oidc_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider_key", "subject", name="ux_oidc_identity_provider_subject"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
# The configured provider key, e.g. "authentik".
|
||||
provider_key = Column(String(50), nullable=False)
|
||||
issuer = Column(String(500), nullable=False)
|
||||
subject = Column(String(255), nullable=False)
|
||||
email = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="oidc_identities")
|
||||
|
||||
Reference in New Issue
Block a user