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
+2
View File
@@ -39,6 +39,7 @@ from .routers import (
policy,
inbox,
mail,
oidc,
quick,
uebertraege,
rates,
@@ -207,6 +208,7 @@ def version() -> dict:
api.include_router(auth.router)
api.include_router(oidc.router)
api.include_router(users.router)
api.include_router(settings_router.router)
api.include_router(policy.router)
+71 -1
View File
@@ -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."""
+108
View File
@@ -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()
+32 -9
View File
@@ -55,7 +55,11 @@ def login(
db: Session = Depends(get_db),
) -> LoginOut:
user = db.query(User).filter(User.username == form.username).first()
if user is None or not verify_password(form.password, user.password_hash):
# Ein reines SSO-Konto hat kein Passwort. Der Vergleich wird gar nicht erst
# versucht – argon2 bekaeme sonst None und wuerfe einen Fehler, aus dem
# herauszulesen waere, dass es diesen Benutzernamen gibt.
ohne_passwort = user is not None and user.password_hash is None
if user is None or ohne_passwort or not verify_password(form.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Benutzername oder Passwort falsch",
@@ -88,6 +92,25 @@ def login(
return LoginOut(status="ok", user=user_out(db, user))
def _bestaetige_mit_passwort(user: User, passwort: str) -> None:
"""Die eigene Identitaet mit dem Passwort bestaetigen.
Ein reines SSO-Konto hat keines – dort ist diese Bestaetigung nicht
"falsch", sondern gar nicht moeglich. Ohne diese Unterscheidung bekaeme
argon2 None und der Nutzer eine Fehlermeldung, die nichts erklaert.
"""
if user.password_hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und "
"hat kein Passwort."),
)
if not verify_password(passwort, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
token = request.cookies.get(get_settings().cookie_name, "")
@@ -111,6 +134,12 @@ def change_password(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UserOut:
if current_user.password_hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und "
"hat kein Passwort."),
)
if not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -247,10 +276,7 @@ def new_recovery_codes(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv"
)
if not verify_password(payload.password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
)
_bestaetige_mit_passwort(current_user, payload.password)
return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user))
@@ -260,10 +286,7 @@ def disable_two_factor(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UserOut:
if not verify_password(payload.password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
)
_bestaetige_mit_passwort(current_user, payload.password)
if policy_service.load(db).mfa_required:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+373
View File
@@ -0,0 +1,373 @@
"""Anmelden über einen fremden Dienst – und ein bestehendes Konto damit verknüpfen.
Zwei Wege durch dieselbe Strecke:
**Verknüpfen** (angemeldet). Der eigentliche Weg für alle, die schon ein Konto
haben: einmal wie gewohnt anmelden, in den Einstellungen „Mit … verknüpfen",
fertig. Danach geht beides – Passwort und SSO.
**Anmelden** (nicht angemeldet). Ist die Kennung verknüpft, wird die Sitzung
angelegt. Ist sie es nicht, entsteht je nach Einstellung ein NEUES Konto – oder
die Anmeldung wird abgewiesen (Vorgabe).
Was es bewusst nicht gibt: automatisches Zusammenführen über die E-Mail. Es
wäre der bequeme Weg und der gefährliche – wer beim Anbieter eine fremde
Adresse einträgt, übernähme damit ein fremdes Konto.
Der zweite Faktor von Certain wird beim SSO-Login übersprungen: Der ist beim
Anbieter schon passiert, und ein zweites Mal fragen hiesse, ihm nicht zu
glauben, obwohl man ihn gerade zur Anmeldung ermächtigt hat.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from .. import oidc_config
from ..config import get_settings
from ..database import get_db
from ..deps import get_current_user
from ..models import OidcState, Role, User, UserIdentity
from ..services import oidc as oidc_service
from ..services import sessions
router = APIRouter(prefix="/auth/oidc", tags=["auth"])
# Wie lange ein begonnener Anmeldeversuch gilt. Kurz: In dieser Zeit soll der
# Nutzer nur beim Anbieter auf "Anmelden" druecken.
STATE_TTL_MINUTEN = 15
def _now() -> datetime:
return datetime.now(timezone.utc)
def _redirect_uri(request: Request, provider: str) -> str:
"""Die Adresse, an die der Anbieter zurueckschickt.
Aus der Anfrage abgeleitet, damit Entwicklung (http://localhost:5173) und
Betrieb (https://…) ohne zweite Einstellung funktionieren. Sie muss beim
Anbieter EXAKT so eingetragen sein – auch beim Einloesen des Codes wird
sie noch einmal mitgeschickt und dort verglichen.
"""
return str(request.url_for("oidc_callback", provider=provider))
def _fehlerseite(weiter: str, meldung: str) -> RedirectResponse:
"""Zurueck in die Oberflaeche, mit dem Grund in der Adresse.
Kein JSON: Hier landet ein BROWSER nach einer Weiterleitung, kein
Programm. Eine nackte Fehlerantwort waere eine weisse Seite.
"""
from urllib.parse import quote
return RedirectResponse(f"{weiter}?oidc_error={quote(meldung[:300])}", status_code=303)
def _sicheres_ziel(weiter: str | None) -> str:
"""Nur Ziele auf dieser Seite.
Ohne diese Pruefung waere der Endpunkt eine offene Weiterleitung: Ein Link
auf ``…/start?weiter=https://woanders`` schickte den Nutzer nach der
Anmeldung auf eine fremde Seite, die wie ein Teil von Certain aussieht.
"""
if not weiter or not weiter.startswith("/") or weiter.startswith("//"):
return "/"
return weiter[:255]
@router.get("/providers")
def list_providers() -> dict:
"""Welche Anmeldedienste eingerichtet sind.
Ohne Anmeldung erreichbar: Die Login-Seite muss vor der Anmeldung wissen,
ob sie einen Knopf zeigen soll. Client-Secrets stehen hier nie.
"""
return {
"providers": [
{"name": p.name, "label": p.label}
for p in oidc_config.providers().values()
]
}
def _hole_provider(provider: str):
p = oidc_config.get(provider)
if p is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Kein Anmeldedienst '{provider}' eingerichtet.",
)
return p
def _beginne(db: Session, request: Request, provider: str, weiter: str | None,
link_user: User | None) -> RedirectResponse:
p = _hole_provider(provider)
ziel = _redirect_uri(request, p.name)
try:
begonnen = oidc_service.start(p, ziel)
except oidc_service.OidcFehler as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e
# Abgelaufene Versuche wegraeumen – sonst waechst die Tabelle still.
db.query(OidcState).filter(OidcState.expires_at < _now()).delete()
db.add(OidcState(
state=begonnen["state"],
provider=p.name,
nonce=begonnen["nonce"],
code_verifier=begonnen["code_verifier"],
redirect_uri=ziel,
link_user_id=link_user.id if link_user is not None else None,
weiter=_sicheres_ziel(weiter),
expires_at=_now() + timedelta(minutes=STATE_TTL_MINUTEN),
))
db.commit()
return RedirectResponse(begonnen["url"], status_code=303)
@router.get("/{provider}/start")
def oidc_start(provider: str, request: Request, weiter: str | None = Query(default=None),
db: Session = Depends(get_db)):
"""Anmeldung beginnen. Ohne Anmeldung erreichbar – das ist der Sinn."""
return _beginne(db, request, provider, weiter, None)
@router.get("/{provider}/link")
def oidc_link(provider: str, request: Request, weiter: str | None = Query(default=None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Das eigene, bestehende Konto mit dem Anbieter verknuepfen."""
return _beginne(db, request, provider, weiter or "/konto", user)
def _freier_benutzername(db: Session, vorschlag: str) -> str:
"""Ein Benutzername, den es noch nicht gibt."""
basis = "".join(c for c in (vorschlag or "").strip().lower()
if c.isalnum() or c in "._-")[:48] or "nutzer"
name = basis
n = 2
while db.query(User.id).filter(User.username == name).first() is not None:
name = f"{basis}{n}"[:64]
n += 1
return name
def _darf_anlegen(p, anmeldung: oidc_service.Anmeldung) -> bool:
if not p.auto_create:
return False
if not p.allowed_domains:
return True
if not anmeldung.email:
return False
return anmeldung.email.rsplit("@", 1)[-1] in p.allowed_domains
def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> User:
"""Zu wem gehoert dieser Anmeldende?
Zwei Faelle, und dazwischen ausdruecklich NICHTS:
1. **Bekannte Kennung** – die Verknuepfung gibt es schon. Eindeutig.
2. **Neu anlegen**, wenn der Anbieter das darf.
Was hier bewusst FEHLT, ist der bequeme dritte Fall: ueber die E-Mail ein
vorhandenes Konto suchen und stillschweigend verknuepfen. Wer beim Anbieter
eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes Konto –
und die E-Mail ist die Angabe, die sich dort am leichtesten aendern laesst.
Ein bestehendes Konto wird ausdruecklich verknuepft, im angemeldeten
Zustand, ueber ``/link``.
"""
vorhanden = (
db.query(UserIdentity)
.filter(UserIdentity.provider == p.name,
UserIdentity.subject == anmeldung.subject)
.first()
)
if vorhanden is not None:
vorhanden.email = anmeldung.email
vorhanden.last_login_at = _now()
nutzer = db.get(User, vorhanden.user_id)
if nutzer is None:
raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.")
return nutzer
if not _darf_anlegen(p, anmeldung):
raise oidc_service.OidcFehler(
"Zu dieser Anmeldung gibt es kein Konto in Certain. Melde dich mit "
"Benutzername und Passwort an und verknuepfe dein Konto in den "
"Einstellungen."
)
nutzer = User(
username=_freier_benutzername(
db, anmeldung.preferred_username
or (anmeldung.email.split("@")[0] if anmeldung.email else "")),
# Kein Passwort: Dieses Konto meldet sich ueber den Anbieter an.
password_hash=None,
display_name=(anmeldung.name or "")[:120] or None,
role=Role.user,
is_active=True,
)
db.add(nutzer)
db.flush()
db.add(UserIdentity(user_id=nutzer.id, provider=p.name,
subject=anmeldung.subject, email=anmeldung.email,
last_login_at=_now()))
return nutzer
@router.get("/{provider}/callback", name="oidc_callback")
def oidc_callback(provider: str, request: Request, response: Response,
code: str | None = Query(default=None),
state: str | None = Query(default=None),
error: str | None = Query(default=None),
error_description: str | None = Query(default=None),
db: Session = Depends(get_db)):
"""Rueckkehr vom Anbieter."""
eintrag = (
db.query(OidcState).filter(OidcState.state == (state or "")).first()
if state else None
)
# Das Ziel steht im gemerkten Versuch; ohne ihn bleibt nur die Startseite.
weiter = _sicheres_ziel(eintrag.weiter if eintrag is not None else None)
if error:
return _fehlerseite(weiter, error_description or error)
if eintrag is None:
# Auch bei falschem state: Ohne Zuordnung waere jede Rueckkehr gueltig.
return _fehlerseite(weiter, "Diese Anmeldung ist abgelaufen oder gehoert "
"nicht hierher. Bitte noch einmal versuchen.")
verbraucht = eintrag # ab hier gilt er als benutzt, egal wie es ausgeht
abgelaufen = eintrag.expires_at.replace(tzinfo=timezone.utc) < _now() \
if eintrag.expires_at.tzinfo is None else eintrag.expires_at < _now()
nonce, verifier, ziel_uri = eintrag.nonce, eintrag.code_verifier, eintrag.redirect_uri
link_user_id = eintrag.link_user_id
db.delete(verbraucht)
db.commit()
if abgelaufen:
return _fehlerseite(weiter, "Die Anmeldung hat zu lange gedauert. "
"Bitte noch einmal versuchen.")
if eintrag.provider != provider:
return _fehlerseite(weiter, "Die Antwort gehoert zu einem anderen Anmeldedienst.")
if not code:
return _fehlerseite(weiter, "Der Anmeldedienst hat keinen Code geschickt.")
p = oidc_config.get(provider)
if p is None:
return _fehlerseite(weiter, f"Kein Anmeldedienst '{provider}' eingerichtet.")
try:
anmeldung = oidc_service.schliesse_ab(p, code, ziel_uri, verifier, nonce)
except oidc_service.OidcFehler as e:
return _fehlerseite(weiter, str(e))
# --- Verknuepfen: der Nutzer ist schon angemeldet -----------------------
if link_user_id is not None:
nutzer = db.get(User, link_user_id)
if nutzer is None:
return _fehlerseite(weiter, "Das Konto gibt es nicht mehr.")
schon = (
db.query(UserIdentity)
.filter(UserIdentity.provider == p.name,
UserIdentity.subject == anmeldung.subject)
.first()
)
if schon is not None and schon.user_id != nutzer.id:
return _fehlerseite(
weiter, "Dieses Konto beim Anmeldedienst gehoert bereits zu einem "
"anderen Benutzer in Certain.")
if schon is None:
db.add(UserIdentity(user_id=nutzer.id, provider=p.name,
subject=anmeldung.subject, email=anmeldung.email,
last_login_at=_now()))
db.commit()
from urllib.parse import quote
return RedirectResponse(f"{weiter}?oidc_linked={quote(p.name)}", status_code=303)
# --- Anmelden -----------------------------------------------------------
try:
nutzer = _finde_oder_lege_an(db, p, anmeldung)
except oidc_service.OidcFehler as e:
db.rollback()
return _fehlerseite(weiter, str(e))
if not nutzer.is_active:
db.rollback()
return _fehlerseite(weiter, "Dieses Konto ist deaktiviert.")
db.commit()
# Kein zweiter Faktor und kein Passwortwechsel: Beides gehoert zum
# Passwort-Login. Der Anbieter hat den Nutzer geprueft, und ein Passwort,
# das gewechselt werden muesste, gibt es hier womoeglich gar nicht.
raw = sessions.create_session(db, nutzer, request.headers.get("user-agent"))
antwort = RedirectResponse(weiter, status_code=303)
settings = get_settings()
antwort.set_cookie(
key=settings.cookie_name,
value=raw,
max_age=settings.session_ttl_days * 86400,
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
path="/",
)
return antwort
@router.get("/identities")
def my_identities(db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Womit dieses Konto verknuepft ist – fuer die Kontoseite."""
bekannt = oidc_config.providers()
return {
"identities": [
{
"id": i.id,
"provider": i.provider,
"label": bekannt[i.provider].label if i.provider in bekannt else i.provider,
"email": i.email,
"last_login_at": i.last_login_at.isoformat() if i.last_login_at else None,
}
for i in db.query(UserIdentity)
.filter(UserIdentity.user_id == user.id)
.order_by(UserIdentity.id)
],
"hat_passwort": user.password_hash is not None,
}
@router.delete("/identities/{identity_id}", status_code=status.HTTP_204_NO_CONTENT)
def unlink(identity_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> None:
"""Eine Verknuepfung loesen.
Nicht die letzte, wenn es kein Passwort gibt: Sonst schliesst sich der
Nutzer aus seinem eigenen Konto aus, und niemand koennte ihn wieder
hereinlassen.
"""
eintrag = (
db.query(UserIdentity)
.filter(UserIdentity.id == identity_id, UserIdentity.user_id == user.id)
.first()
)
if eintrag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Verknuepfung nicht gefunden")
uebrig = (
db.query(UserIdentity)
.filter(UserIdentity.user_id == user.id, UserIdentity.id != identity_id)
.count()
)
if uebrig == 0 and user.password_hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Das ist die einzige Anmeldung fuer dieses Konto. Setz erst "
"ein Passwort, sonst kommst du nicht mehr herein."),
)
db.delete(eintrag)
db.commit()
+6
View File
@@ -51,6 +51,7 @@ from ..models import (
InboxItem,
Subscription,
SubscriptionLogo,
UserIdentity,
LinkIgnored,
PlannedIncomeLine,
RecoveryCode,
@@ -156,6 +157,11 @@ USER_TABLES: list[TableSpec] = [
# der Hash – der Klartext existiert nirgends.
TableSpec("api_tokens", ApiToken, "owner",
{"default_account_id": "accounts"}, label="Geräteschlüssel"),
# Aus demselben Grund wie die Geräteschlüssel: Ohne sie kommt nach einem
# Wiedereinspielen niemand mehr über den Anmeldedienst herein, und wer kein
# Passwort hat, gar nicht mehr. Enthält kein Geheimnis – nur die Kennung,
# unter der der Anbieter diesen Menschen führt.
TableSpec("user_identities", UserIdentity, "user", label="SSO-Verknüpfungen"),
]
# Bewusst NICHT gesichert:
+268
View File
@@ -0,0 +1,268 @@
"""OpenID Connect: Anmeldung ueber einen fremden Anbieter (Authorization Code Flow).
Geprueft wird mit Authlib, nicht von Hand. Ein ID-Token selbst zu zerlegen und
seine Signatur nachzurechnen ist die Art Code, die in neunzig Prozent der
Faelle funktioniert und in den uebrigen zehn jemanden hereinlaesst, der nicht
hereingehoert.
Was hier passiert und warum:
``state`` Verknuepft die Rueckkehr mit dem Aufbruch. Ohne ihn koennte jemand
eine fremde Rueckleitung in den Browser des Nutzers schieben.
``nonce`` Steht im ID-Token und wird gegen den gemerkten Wert geprueft. Ohne
ihn liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
``PKCE`` Bindet den Code an den, der ihn angefordert hat. Bei einem
vertraulichen Client (Secret) nicht zwingend, aber kostenlos – und
der Code bleibt derselbe, falls einmal ein oeffentlicher Client
dazukommt.
Discovery und Schluessel werden kurz zwischengespeichert. Nicht laenger: Ein
Anbieter wechselt seine Signaturschluessel, und ein zu lange gehaltener
Schluesselsatz weist danach jeden ab.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from dataclasses import dataclass
import httpx
from authlib.jose import JsonWebKey, JsonWebToken
from authlib.jose.errors import JoseError
from ..oidc_config import Provider
log = logging.getLogger(__name__)
ZEITLIMIT = 15.0
# So lange gelten Discovery-Dokument und Schluesselsatz als frisch.
CACHE_SEKUNDEN = 600
# Wie viel Uhrzeit-Unterschied zwischen uns und dem Anbieter geduldet wird.
TOLERANZ_SEKUNDEN = 60
# Nur asymmetrische Verfahren. "none" waere keine Signatur, und HS256 pruefte
# gegen das Client-Secret – beides gehoert hier nicht hin.
ERLAUBTE_ALGORITHMEN = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"]
class OidcFehler(RuntimeError):
"""Fehler mit einem Grund, den man dem Nutzer zeigen kann."""
@dataclass
class Anmeldung:
"""Was nach erfolgreicher Pruefung ueber den Anmeldenden feststeht."""
subject: str
email: str | None
email_verified: bool
name: str | None
preferred_username: str | None
_cache: dict[str, tuple[float, dict]] = {}
_schluessel_cache: dict[str, tuple[float, object]] = {}
def _hole(url: str) -> dict:
try:
with httpx.Client(timeout=ZEITLIMIT, follow_redirects=True) as c:
antwort = c.get(url)
antwort.raise_for_status()
return antwort.json()
except httpx.HTTPError as e:
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
except ValueError as e:
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
def discovery(p: Provider) -> dict:
"""Das Discovery-Dokument – gecacht."""
eintrag = _cache.get(p.name)
if eintrag and eintrag[0] > time.time():
return eintrag[1]
daten = _hole(p.discovery_url)
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
if not daten.get(pflicht):
raise OidcFehler(
f"Das Discovery-Dokument von '{p.name}' hat kein '{pflicht}'. "
f"Zeigt die Adresse wirklich auf eine OpenID-Konfiguration?"
)
_cache[p.name] = (time.time() + CACHE_SEKUNDEN, daten)
return daten
def _schluesselsatz(p: Provider, jwks_uri: str, erzwinge_neu: bool = False):
eintrag = _schluessel_cache.get(p.name)
if eintrag and eintrag[0] > time.time() and not erzwinge_neu:
return eintrag[1]
satz = JsonWebKey.import_key_set(_hole(jwks_uri))
_schluessel_cache[p.name] = (time.time() + CACHE_SEKUNDEN, satz)
return satz
def cache_leeren() -> None:
_cache.clear()
_schluessel_cache.clear()
# --- Schritt 1: hinschicken --------------------------------------------------
def _pkce() -> tuple[str, str]:
"""(verifier, challenge) nach S256."""
verifier = secrets.token_urlsafe(64)
rumpf = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(rumpf).rstrip(b"=").decode("ascii")
def start(p: Provider, redirect_uri: str) -> dict:
"""Adresse, zu der der Nutzer geschickt wird – plus was wir uns merken muessen."""
from urllib.parse import urlencode
daten = discovery(p)
verifier, challenge = _pkce()
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
frage = {
"response_type": "code",
"client_id": p.client_id,
"redirect_uri": redirect_uri,
"scope": p.scopes,
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return {
"url": f"{daten['authorization_endpoint']}?{urlencode(frage)}",
"state": state,
"nonce": nonce,
"code_verifier": verifier,
}
# --- Schritt 2: zurueckkommen ------------------------------------------------
def _tausche_code(p: Provider, daten: dict, code: str, redirect_uri: str,
code_verifier: str) -> dict:
formular = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": p.client_id,
"code_verifier": code_verifier,
}
kopfzeilen = {"Accept": "application/json"}
# Ein oeffentlicher Client hat kein Secret und darf auch keines schicken.
auth = (p.client_id, p.client_secret) if p.client_secret else None
if auth is None:
formular.pop("client_id", None)
formular["client_id"] = p.client_id
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.post(daten["token_endpoint"], data=formular,
headers=kopfzeilen, auth=auth)
except httpx.HTTPError as e:
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
if antwort.status_code >= 400:
grund = ""
try:
inhalt = antwort.json()
grund = inhalt.get("error_description") or inhalt.get("error") or ""
except ValueError:
pass
raise OidcFehler(
f"Der Anmeldedienst hat den Code abgelehnt{': ' + grund if grund else '.'}"
)
try:
return antwort.json()
except ValueError as e:
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
def _pruefe_id_token(p: Provider, daten: dict, id_token: str, nonce: str) -> dict:
"""Signatur, Aussteller, Empfaenger, Laufzeit und nonce.
Bei einem unbekannten Schluessel wird der Satz EINMAL neu geholt: Anbieter
tauschen ihre Signaturschluessel aus, und ein gecachter Satz wiese danach
jeden ab, bis der Zwischenspeicher von selbst ablaeuft.
"""
anspruch = {
"iss": {"essential": True, "value": daten["issuer"]},
"aud": {"essential": True, "value": p.client_id},
"exp": {"essential": True},
}
def _lauf(erzwinge_neu: bool) -> dict:
satz = _schluesselsatz(p, daten["jwks_uri"], erzwinge_neu)
jwt = JsonWebToken(ERLAUBTE_ALGORITHMEN)
gelesen = jwt.decode(id_token, satz, claims_options=anspruch)
gelesen.validate(leeway=TOLERANZ_SEKUNDEN)
return gelesen
try:
gelesen = _lauf(False)
except (JoseError, ValueError):
try:
gelesen = _lauf(True)
except JoseError as e:
raise OidcFehler(f"Das Token des Anmeldedienstes ist ungueltig: {e}") from e
except ValueError as e:
raise OidcFehler("Das Token des Anmeldedienstes ist unlesbar.") from e
# Der nonce wird von Authlib nicht mitgeprueft – hier von Hand, sonst
# liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
if nonce and gelesen.get("nonce") != nonce:
raise OidcFehler("Die Antwort gehoert nicht zu dieser Anmeldung (nonce).")
if not gelesen.get("sub"):
raise OidcFehler("Das Token nennt keinen Nutzer (sub fehlt).")
return dict(gelesen)
def schliesse_ab(p: Provider, code: str, redirect_uri: str, code_verifier: str,
nonce: str) -> Anmeldung:
"""Code einloesen, ID-Token pruefen, und sagen wer da hereinkommt."""
daten = discovery(p)
tokens = _tausche_code(p, daten, code, redirect_uri, code_verifier)
id_token = tokens.get("id_token")
if not id_token:
raise OidcFehler(
"Der Anmeldedienst hat kein ID-Token geschickt. Fehlt der Scope "
"'openid' in der Konfiguration?"
)
anspruch = _pruefe_id_token(p, daten, id_token, nonce)
email = anspruch.get("email")
verifiziert = bool(anspruch.get("email_verified"))
# Manche Anbieter liefern die E-Mail erst am userinfo-Endpunkt.
if not email and daten.get("userinfo_endpoint") and tokens.get("access_token"):
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.get(
daten["userinfo_endpoint"],
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
if antwort.status_code < 400:
weiteres = antwort.json()
# Nur vom selben Nutzer – sonst mischten wir zwei Identitaeten.
if weiteres.get("sub") == anspruch["sub"]:
email = weiteres.get("email") or email
verifiziert = bool(weiteres.get("email_verified")) or verifiziert
anspruch.setdefault("name", weiteres.get("name"))
anspruch.setdefault("preferred_username",
weiteres.get("preferred_username"))
except (httpx.HTTPError, ValueError):
log.info("userinfo von '%s' nicht abrufbar – ID-Token genuegt", p.name)
return Anmeldung(
subject=str(anspruch["sub"]),
email=(email or "").strip().lower() or None,
email_verified=verifiziert,
name=anspruch.get("name"),
preferred_username=anspruch.get("preferred_username"),
)
@@ -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")
+5
View File
@@ -10,6 +10,11 @@ argon2-cffi==23.1.0
pyotp==2.9.0
qrcode==8.0
python-multipart==0.0.20
# Anmeldung ueber einen fremden Dienst (OpenID Connect): Discovery, JWKS und
# die Pruefung des ID-Tokens. Eine etablierte Bibliothek statt Eigenbau – ein
# selbst geschriebener Token-Pruefer funktioniert in neunzig Prozent der Faelle
# und laesst in den uebrigen zehn jemanden herein.
Authlib==1.6.5
httpx==0.28.1
python-dateutil==2.9.0.post0
# PDF/Bild-Import (Phase 5): Text aus PDFs (PyMuPDF) und OCR (Tesseract) für Scans/Bilder.
+433
View File
@@ -0,0 +1,433 @@
"""Anmeldung ueber einen fremden Dienst.
Getestet wird gegen einen ECHTEN, aber gefaelschten Anbieter: eigenes
RSA-Schluesselpaar, eigenes Discovery-Dokument, eigenes JWKS. Ein Doppelgaenger,
der nur "ja" sagt, wuerde genau das nicht pruefen, worauf es hier ankommt –
naemlich dass ein falsch signiertes, abgelaufenes oder fuer jemand anderen
ausgestelltes Token abgewiesen wird.
"""
import json
import secrets
import time
import pytest
from authlib.jose import JsonWebKey, JsonWebToken
from conftest import ADMIN_PASSWORD, login_ready, new_user
ISSUER = "https://authentik.test"
CLIENT_ID = "certain-test"
DISCOVERY = f"{ISSUER}/application/o/certain/.well-known/openid-configuration"
@pytest.fixture()
def schluessel():
"""Ein RSA-Schluesselpaar, mit dem der gefaelschte Anbieter signiert."""
return JsonWebKey.generate_key("RSA", 2048, is_private=True)
@pytest.fixture()
def anbieter(monkeypatch, schluessel):
"""Richtet einen Anbieter ein und faengt seine Netzaufrufe ab.
Gibt eine Funktion zurueck, mit der ein Test bestimmt, welche Angaben im
naechsten ID-Token stehen.
"""
from app import oidc_config
from app.services import oidc as oidc_service
monkeypatch.setenv("APP_OIDC_PROVIDERS", "authentik")
monkeypatch.setenv("APP_OIDC_AUTHENTIK_DISCOVERY_URL", DISCOVERY)
monkeypatch.setenv("APP_OIDC_AUTHENTIK_CLIENT_ID", CLIENT_ID)
monkeypatch.setenv("APP_OIDC_AUTHENTIK_CLIENT_SECRET", "geheim")
monkeypatch.setenv("APP_OIDC_AUTHENTIK_LABEL", "Authentik")
oidc_config.zuruecksetzen()
oidc_service.cache_leeren()
zustand = {
"claims": {},
"signieren_mit": schluessel,
"token_status": 200,
}
oeffentlich = json.loads(schluessel.as_json(is_private=False))
oeffentlich["kid"] = oeffentlich.get("kid") or "test"
jwks = {"keys": [oeffentlich]}
dokument = {
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}/application/o/authorize/",
"token_endpoint": f"{ISSUER}/application/o/token/",
"jwks_uri": f"{ISSUER}/jwks",
"userinfo_endpoint": f"{ISSUER}/application/o/userinfo/",
}
def _id_token(nonce: str) -> str:
jetzt = int(time.time())
inhalt = {
"iss": ISSUER, "aud": CLIENT_ID, "sub": "sub-standard",
"iat": jetzt, "exp": jetzt + 300, "nonce": nonce,
}
inhalt.update(zustand["claims"])
jwt = JsonWebToken(["RS256"])
return jwt.encode({"alg": "RS256"}, inhalt,
zustand["signieren_mit"]).decode("ascii")
class FakeAntwort:
def __init__(self, daten, status=200):
self._daten = daten
self.status_code = status
def json(self):
return self._daten
def raise_for_status(self):
if self.status_code >= 400:
import httpx
raise httpx.HTTPError(f"status {self.status_code}")
class FakeClient:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, **kw):
if url == DISCOVERY:
return FakeAntwort(dokument)
if url == dokument["jwks_uri"]:
return FakeAntwort(jwks)
if url == dokument["userinfo_endpoint"]:
return FakeAntwort({"sub": "sub-standard"})
return FakeAntwort({}, 404)
def post(self, url, data=None, headers=None, auth=None, **kw):
if url != dokument["token_endpoint"]:
return FakeAntwort({}, 404)
if zustand["token_status"] >= 400:
return FakeAntwort({"error": "invalid_grant"}, zustand["token_status"])
zustand["gesehen"] = {"data": dict(data or {}), "auth": auth}
nonce = zustand.get("nonce", "")
return FakeAntwort({
"access_token": "at", "token_type": "Bearer",
"id_token": _id_token(nonce),
})
monkeypatch.setattr("app.services.oidc.httpx.Client", FakeClient)
yield zustand
oidc_config.zuruecksetzen()
oidc_service.cache_leeren()
def _beginne(client, pfad="/api/auth/oidc/authentik/start", **params):
"""Anmeldung/Verknuepfung starten und den state aus der Adresse holen."""
from urllib.parse import parse_qs, urlparse
r = client.get(pfad, params=params, follow_redirects=False)
assert r.status_code == 303, r.text
frage = parse_qs(urlparse(r.headers["location"]).query)
return frage["state"][0], frage["nonce"][0], frage
def _zurueck(client, zustand, state, nonce, code="guter-code"):
zustand["nonce"] = nonce
return client.get("/api/auth/oidc/authentik/callback",
params={"code": code, "state": state},
follow_redirects=False)
# --- Einrichtung -------------------------------------------------------------
def test_ohne_konfiguration_gibt_es_keinen_anbieter(client):
"""Nichts eingerichtet heisst: Die Login-Seite zeigt keinen Knopf."""
from app import oidc_config
oidc_config.zuruecksetzen()
r = client.get("/api/auth/oidc/providers")
assert r.status_code == 200
assert r.json()["providers"] == []
def test_anbieter_wird_ohne_geheimnis_gemeldet(client, anbieter):
r = client.get("/api/auth/oidc/providers")
assert r.json()["providers"] == [{"name": "authentik", "label": "Authentik"}]
assert "geheim" not in r.text, "Client-Secret nach aussen gegeben"
def test_unbekannter_anbieter_ist_ein_vierhundertvier(client, anbieter):
r = client.get("/api/auth/oidc/gibtsnicht/start", follow_redirects=False)
assert r.status_code == 404
# --- Der Weg hin -------------------------------------------------------------
def test_start_schickt_mit_pkce_und_nonce_zum_anbieter(client, anbieter):
state, nonce, frage = _beginne(client)
assert frage["client_id"] == [CLIENT_ID]
assert frage["response_type"] == ["code"]
assert frage["code_challenge_method"] == ["S256"], "kein PKCE"
assert frage["code_challenge"][0]
assert state and nonce
assert "openid" in frage["scope"][0]
# Der verifier darf NICHT mitgeschickt werden – sonst waere PKCE sinnlos.
assert "code_verifier" not in frage
def test_zwei_anlaeufe_bekommen_verschiedene_kennungen(client, anbieter):
s1, n1, _ = _beginne(client)
s2, n2, _ = _beginne(client)
assert s1 != s2 and n1 != n2
# --- Der Weg zurueck ---------------------------------------------------------
def test_erste_anmeldung_ohne_verknuepfung_wird_abgewiesen(client, anbieter):
"""Vorgabe: kein Konto von selbst. Wer schon eines hat, soll verknuepfen –
sonst bekaeme er ein zweites, leeres."""
state, nonce, _ = _beginne(client)
r = _zurueck(client, anbieter, state, nonce)
assert r.status_code == 303
assert "oidc_error" in r.headers["location"]
assert client.get("/api/auth/me").status_code == 401, "trotzdem angemeldet"
def test_mit_auto_create_entsteht_ein_konto(client, anbieter, monkeypatch):
from app import oidc_config
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
anbieter["claims"] = {"sub": "neu-" + secrets.token_hex(4),
"email": "neu@example.com", "email_verified": True,
"preferred_username": "neuer" + secrets.token_hex(3)}
state, nonce, _ = _beginne(client)
r = _zurueck(client, anbieter, state, nonce)
assert r.status_code == 303, r.text
assert "oidc_error" not in r.headers["location"], r.headers["location"]
ich = client.get("/api/auth/me")
assert ich.status_code == 200, "nicht angemeldet"
assert ich.json()["role"] == "user", "neues Konto darf kein Admin sein"
client.post("/api/auth/logout")
def test_falsche_signatur_kommt_nicht_herein(client, anbieter, monkeypatch):
"""Der Kern: Ein Token, das jemand anders signiert hat, ist wertlos."""
from app import oidc_config
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
anbieter["signieren_mit"] = JsonWebKey.generate_key("RSA", 2048, is_private=True)
anbieter["claims"] = {"sub": "faelscher"}
state, nonce, _ = _beginne(client)
r = _zurueck(client, anbieter, state, nonce)
assert "oidc_error" in r.headers["location"]
assert client.get("/api/auth/me").status_code == 401
def test_fremder_empfaenger_kommt_nicht_herein(client, anbieter, monkeypatch):
"""Ein Token, das fuer eine ANDERE Anwendung ausgestellt wurde."""
from app import oidc_config
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
anbieter["claims"] = {"sub": "fremd", "aud": "eine-andere-app"}
state, nonce, _ = _beginne(client)
r = _zurueck(client, anbieter, state, nonce)
assert "oidc_error" in r.headers["location"]
assert client.get("/api/auth/me").status_code == 401
def test_abgelaufenes_token_kommt_nicht_herein(client, anbieter, monkeypatch):
from app import oidc_config
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
jetzt = int(time.time())
anbieter["claims"] = {"sub": "alt", "iat": jetzt - 4000, "exp": jetzt - 3600}
state, nonce, _ = _beginne(client)
r = _zurueck(client, anbieter, state, nonce)
assert "oidc_error" in r.headers["location"]
assert client.get("/api/auth/me").status_code == 401
def test_falscher_nonce_kommt_nicht_herein(client, anbieter, monkeypatch):
"""Sonst liesse sich ein anderswo erbeutetes, gueltiges Token einreichen."""
from app import oidc_config
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
anbieter["claims"] = {"sub": "nonce-test"}
state, nonce, _ = _beginne(client)
r = _zurueck(client, anbieter, state, "ein-ganz-anderer-nonce")
assert "oidc_error" in r.headers["location"]
assert client.get("/api/auth/me").status_code == 401
def test_unbekannter_state_wird_abgewiesen(client, anbieter):
"""Ohne diese Pruefung waere jede Rueckkehr gueltig."""
r = client.get("/api/auth/oidc/authentik/callback",
params={"code": "x", "state": "nie-vergeben"},
follow_redirects=False)
assert r.status_code == 303
assert "oidc_error" in r.headers["location"]
def test_ein_state_gilt_nur_einmal(client, anbieter, monkeypatch):
"""Ein Code darf genau einmal eingeloest werden."""
from app import oidc_config
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
anbieter["claims"] = {"sub": "einmal-" + secrets.token_hex(4),
"preferred_username": "einmal" + secrets.token_hex(3)}
state, nonce, _ = _beginne(client)
assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"]
client.post("/api/auth/logout")
zweiter = _zurueck(client, anbieter, state, nonce)
assert "oidc_error" in zweiter.headers["location"], "state ein zweites Mal gueltig"
assert client.get("/api/auth/me").status_code == 401
def test_offene_weiterleitung_ist_nicht_moeglich(client, anbieter):
"""`weiter` darf nur auf diese Seite zeigen – sonst waere der Endpunkt eine
Schleuder auf fremde Seiten, die wie Certain aussehen."""
state, nonce, _ = _beginne(client, weiter="https://boese.example/phish")
r = _zurueck(client, anbieter, state, nonce)
assert not r.headers["location"].startswith("http"), r.headers["location"]
# --- Verknuepfen -------------------------------------------------------------
def test_bestehendes_konto_verknuepfen_und_danach_damit_anmelden(admin, client, anbieter):
"""Der eigentliche Weg: einmal normal anmelden, verknuepfen, fertig."""
sub = "guido-" + secrets.token_hex(4)
anbieter["claims"] = {"sub": sub, "email": "guido@example.com",
"email_verified": True}
state, nonce, _ = _beginne(admin, "/api/auth/oidc/authentik/link")
r = _zurueck(admin, anbieter, state, nonce)
assert "oidc_error" not in r.headers["location"], r.headers["location"]
assert "oidc_linked" in r.headers["location"]
ident = admin.get("/api/auth/oidc/identities").json()
assert [i["provider"] for i in ident["identities"]] == ["authentik"]
assert ident["hat_passwort"] is True
# Abmelden und jetzt ueber den Anbieter hereinkommen – dasselbe Konto.
admin.post("/api/auth/logout")
state, nonce, _ = _beginne(admin)
r = _zurueck(admin, anbieter, state, nonce)
assert "oidc_error" not in r.headers["location"], r.headers["location"]
ich = admin.get("/api/auth/me")
assert ich.status_code == 200
assert ich.json()["username"] == "admin", "in einem fremden Konto gelandet"
# Aufraeumen, damit die uebrigen Tests den Admin unveraendert vorfinden.
ident = admin.get("/api/auth/oidc/identities").json()["identities"][0]
assert admin.delete(f"/api/auth/oidc/identities/{ident['id']}").status_code == 204
admin.post("/api/auth/login",
data={"username": "admin", "password": ADMIN_PASSWORD})
def test_eine_kennung_gehoert_nur_einem_konto(admin, anbieter):
"""Sonst landete derselbe Anmeldende mal hier und mal dort."""
sub = "geteilt-" + secrets.token_hex(4)
anbieter["claims"] = {"sub": sub}
state, nonce, _ = _beginne(admin, "/api/auth/oidc/authentik/link")
assert "oidc_linked" in _zurueck(admin, anbieter, state, nonce).headers["location"]
eigene = admin.get("/api/auth/oidc/identities").json()["identities"][0]
username, passwort = new_user(admin)
anderer, _ = login_ready(username, passwort)
state, nonce, _ = _beginne(anderer, "/api/auth/oidc/authentik/link")
r = _zurueck(anderer, anbieter, state, nonce)
assert "oidc_error" in r.headers["location"], "dieselbe Kennung zweimal verknuepft"
assert anderer.get("/api/auth/oidc/identities").json()["identities"] == []
admin.delete(f"/api/auth/oidc/identities/{eigene['id']}")
def test_verknuepfen_verlangt_eine_anmeldung(client, anbieter):
r = client.get("/api/auth/oidc/authentik/link", follow_redirects=False)
assert r.status_code == 401
def test_letzte_verknuepfung_bleibt_wenn_es_kein_passwort_gibt(admin, anbieter, monkeypatch):
"""Sonst schliesst sich der Nutzer aus seinem eigenen Konto aus."""
from app import oidc_config
from app.database import SessionLocal
from app.models import User, UserIdentity
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
sub = "ohnepw-" + secrets.token_hex(4)
anbieter["claims"] = {"sub": sub, "preferred_username": "ohnepw" + secrets.token_hex(3)}
state, nonce, _ = _beginne(admin)
admin.post("/api/auth/logout")
# Frischer Client, damit die Sitzung des neuen Kontos gilt.
from fastapi.testclient import TestClient
from app.main import app
neu = TestClient(app)
state, nonce, _ = _beginne(neu)
assert "oidc_error" not in _zurueck(neu, anbieter, state, nonce).headers["location"]
daten = neu.get("/api/auth/oidc/identities").json()
assert daten["hat_passwort"] is False, "SSO-Konto hat wider Erwarten ein Passwort"
ident_id = daten["identities"][0]["id"]
r = neu.delete(f"/api/auth/oidc/identities/{ident_id}")
assert r.status_code == 400, "letzte Anmeldung liess sich entfernen"
assert "Passwort" in r.json()["detail"]
with SessionLocal() as s:
eintrag = s.query(UserIdentity).filter(UserIdentity.subject == sub).first()
assert eintrag is not None, "Verknuepfung wurde doch geloescht"
s.delete(s.get(User, eintrag.user_id))
s.commit()
# --- Zusammenspiel mit dem Passwort-Login ------------------------------------
def test_passwort_login_bleibt_unveraendert(client):
"""Der neue Weg kommt DAZU. Der alte muss weiter funktionieren."""
r = client.post("/api/auth/login",
data={"username": "admin", "password": ADMIN_PASSWORD})
assert r.status_code == 200, r.text
client.post("/api/auth/logout")
def test_konto_ohne_passwort_faellt_beim_passwort_login_nicht_durch(client, anbieter,
monkeypatch):
"""argon2 bekaeme sonst None und wuerfe einen Fehler, aus dem sich ablesen
liesse, dass es diesen Benutzernamen gibt."""
from app import oidc_config
from app.database import SessionLocal
from app.models import User, UserIdentity
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
name = "sso" + secrets.token_hex(4)
sub = "sso-" + secrets.token_hex(4)
anbieter["claims"] = {"sub": sub, "preferred_username": name}
state, nonce, _ = _beginne(client)
assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"]
client.post("/api/auth/logout")
r = client.post("/api/auth/login", data={"username": name, "password": "irgendwas"})
assert r.status_code == 401, r.text
assert "Passwort falsch" in r.json()["detail"]
with SessionLocal() as s:
eintrag = s.query(UserIdentity).filter(UserIdentity.subject == sub).first()
s.delete(s.get(User, eintrag.user_id))
s.commit()