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
@@ -0,0 +1,108 @@
|
||||
"""Welche OpenID-Connect-Anbieter eingerichtet sind.
|
||||
|
||||
Bewusst aus Umgebungsvariablen und nicht aus der Datenbank: Client-ID und
|
||||
-Secret gehoeren zur Betriebsumgebung, nicht in ein Backup, und wer die
|
||||
Anwendung umzieht, soll sie neu setzen muessen.
|
||||
|
||||
Ein zweiter Anbieter ist reine Konfiguration – kein Code aendert sich:
|
||||
|
||||
APP_OIDC_PROVIDERS=authentik,google
|
||||
APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example/application/o/certain/.well-known/openid-configuration
|
||||
APP_OIDC_AUTHENTIK_CLIENT_ID=...
|
||||
APP_OIDC_AUTHENTIK_CLIENT_SECRET=...
|
||||
APP_OIDC_AUTHENTIK_LABEL=Authentik
|
||||
|
||||
Der Schluessel in ``APP_OIDC_PROVIDERS`` ist zugleich der Name in der Adresse
|
||||
(``/api/auth/oidc/authentik/start``) und in der Datenbank – er darf sich
|
||||
spaeter nicht aendern, sonst finden bestehende Verknuepfungen ihren Anbieter
|
||||
nicht mehr wieder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
|
||||
# Nur was sich gefahrlos in eine URL und eine Datenbankspalte schreiben laesst.
|
||||
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
||||
|
||||
# Ohne diese drei Scopes fehlt entweder das ID-Token oder die E-Mail, ueber die
|
||||
# ein bestehendes Konto gefunden wird.
|
||||
STANDARD_SCOPES = "openid profile email"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provider:
|
||||
name: str # "authentik" – Schluessel in URL und Datenbank
|
||||
label: str # "Mit Authentik anmelden" – was auf dem Knopf steht
|
||||
discovery_url: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
scopes: str = STANDARD_SCOPES
|
||||
# Darf jemand, den es in Certain noch nicht gibt, ein Konto bekommen?
|
||||
#
|
||||
# Vorgabe NEIN, und das ist Absicht: Wer schon ein Konto hat und sich zum
|
||||
# ersten Mal ueber den Anbieter anmeldet, bekaeme sonst ein zweites, leeres
|
||||
# – seine Daten laegen weiter unter dem alten. Abgewiesen zu werden mit dem
|
||||
# Hinweis "erst anmelden, dann verknuepfen" ist die hilfreichere Antwort.
|
||||
#
|
||||
# Auf true stellen, wenn sich neue Leute selbst anmelden koennen sollen.
|
||||
# Dann entscheidet der Anbieter, wer in Certain darf.
|
||||
auto_create: bool = False
|
||||
# Wenn gesetzt: nur E-Mails dieser Domaenen duerfen neu anlegen.
|
||||
allowed_domains: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
def _env(name: str, provider: str, standard: str = "") -> str:
|
||||
return os.environ.get(f"APP_OIDC_{provider.upper()}_{name}", standard).strip()
|
||||
|
||||
|
||||
def _bool(wert: str, standard: bool) -> bool:
|
||||
if not wert:
|
||||
return standard
|
||||
return wert.lower() in ("1", "true", "yes", "ja", "on")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def providers() -> dict[str, Provider]:
|
||||
"""Die eingerichteten Anbieter, nach Namen.
|
||||
|
||||
Unvollstaendig konfigurierte werden ausgelassen und NICHT halb angeboten:
|
||||
Ein Knopf, der ins Leere fuehrt, ist schlimmer als keiner.
|
||||
"""
|
||||
roh = os.environ.get("APP_OIDC_PROVIDERS", "")
|
||||
gefunden: dict[str, Provider] = {}
|
||||
for name in (n.strip().lower() for n in roh.split(",")):
|
||||
if not name or not NAME_MUSTER.match(name):
|
||||
continue
|
||||
discovery = _env("DISCOVERY_URL", name)
|
||||
client_id = _env("CLIENT_ID", name)
|
||||
if not discovery or not client_id:
|
||||
continue
|
||||
domains = tuple(
|
||||
d.strip().lower().lstrip("@")
|
||||
for d in _env("ALLOWED_DOMAINS", name).split(",")
|
||||
if d.strip()
|
||||
)
|
||||
gefunden[name] = Provider(
|
||||
name=name,
|
||||
label=_env("LABEL", name) or name.capitalize(),
|
||||
discovery_url=discovery,
|
||||
client_id=client_id,
|
||||
client_secret=_env("CLIENT_SECRET", name),
|
||||
scopes=_env("SCOPES", name) or STANDARD_SCOPES,
|
||||
auto_create=_bool(_env("AUTO_CREATE", name), False),
|
||||
allowed_domains=domains,
|
||||
)
|
||||
return gefunden
|
||||
|
||||
|
||||
def get(name: str) -> Provider | None:
|
||||
return providers().get((name or "").lower())
|
||||
|
||||
|
||||
def zuruecksetzen() -> None:
|
||||
"""Den Zwischenspeicher leeren – fuer Tests, die die Umgebung aendern."""
|
||||
providers.cache_clear()
|
||||
Reference in New Issue
Block a user