Files
ScarriffleandClaude Opus 5 7690718731 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>
2026-09-09 17:28:24 +02:00

109 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()