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
@@ -0,0 +1,239 @@
|
||||
"""Mapping a verified OIDC identity onto a Calendarr user account.
|
||||
|
||||
The security-relevant decisions live here:
|
||||
|
||||
* ``(provider_key, sub)`` is the only automatic match. ``sub`` is the one claim
|
||||
an identity provider guarantees to be stable and unique; email and username
|
||||
are not.
|
||||
* Matching a verified email onto an *existing* local account is refused by
|
||||
default. Anyone who can set an arbitrary email on an IdP account would
|
||||
otherwise inherit the matching Calendarr account — including an admin's.
|
||||
Linking is an explicit, authenticated action instead.
|
||||
* Just-in-time provisioning is off unless the provider enables it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_password_hash
|
||||
from oidc_config import OIDCProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USERNAME_MAX = 50
|
||||
_USERNAME_STRIP = re.compile(r"[^a-z0-9._-]")
|
||||
|
||||
|
||||
class OIDCPolicyError(Exception):
|
||||
"""A refusal that is the user's/admin's to resolve, not a protocol fault.
|
||||
|
||||
``slug`` maps to a translated message on the login screen.
|
||||
"""
|
||||
|
||||
def __init__(self, slug: str, detail: str = ""):
|
||||
super().__init__(detail or slug)
|
||||
self.slug = slug
|
||||
self.detail = detail or slug
|
||||
|
||||
|
||||
def claim_email(claims: dict) -> str:
|
||||
return (claims.get("email") or "").strip().lower()
|
||||
|
||||
|
||||
def _normalise_username(raw: str) -> str:
|
||||
"""Fold a claim value into the shape `users.username` requires."""
|
||||
if not raw:
|
||||
return ""
|
||||
folded = unicodedata.normalize("NFKD", raw)
|
||||
folded = folded.encode("ascii", "ignore").decode("ascii").lower()
|
||||
folded = _USERNAME_STRIP.sub("", folded)
|
||||
return folded.strip("._-")[:USERNAME_MAX]
|
||||
|
||||
|
||||
def _username_taken(db: Session, name: str) -> bool:
|
||||
return db.query(models.User).filter(
|
||||
func.lower(models.User.username) == name.lower()
|
||||
).first() is not None
|
||||
|
||||
|
||||
def derive_username(db: Session, claims: dict, provider: OIDCProvider) -> str:
|
||||
"""Pick a free, valid username for a newly provisioned SSO user.
|
||||
|
||||
A collision here is a *different human* who happens to share a
|
||||
preferred_username, so we never reuse an existing name — that would be
|
||||
instant account takeover.
|
||||
"""
|
||||
candidates = [
|
||||
claims.get(provider.username_claim),
|
||||
claim_email(claims).split("@")[0] if claim_email(claims) else "",
|
||||
"u" + hashlib.sha256((claims.get("sub") or "").encode()).hexdigest()[:12],
|
||||
]
|
||||
base = ""
|
||||
for candidate in candidates:
|
||||
base = _normalise_username(candidate or "")
|
||||
if base:
|
||||
break
|
||||
if not base:
|
||||
base = "u" + secrets.token_hex(6)
|
||||
|
||||
if not _username_taken(db, base):
|
||||
return base
|
||||
|
||||
for suffix in range(2, 51):
|
||||
tail = f"-{suffix}"
|
||||
name = base[: USERNAME_MAX - len(tail)] + tail
|
||||
if not _username_taken(db, name):
|
||||
return name
|
||||
|
||||
# Pathological case: fall back to something effectively unique.
|
||||
tail = "-" + secrets.token_hex(5)
|
||||
return base[: USERNAME_MAX - len(tail)] + tail
|
||||
|
||||
|
||||
def _create_user(db: Session, provider: OIDCProvider, claims: dict,
|
||||
email: str) -> models.User:
|
||||
"""Provision a new account. Mirrors users_router.create_user."""
|
||||
# `users.password_hash` is NOT NULL and `auth.verify_password` feeds it
|
||||
# straight to bcrypt.checkpw, which raises ValueError on anything that is
|
||||
# not a real hash — that surfaces as HTTP 500 on /api/auth/login and on
|
||||
# every CalDAV Basic-Auth attempt. So store a real bcrypt hash of a random
|
||||
# secret that is never written down anywhere: unusable, but well-formed.
|
||||
user = models.User(
|
||||
username=derive_username(db, claims, provider),
|
||||
display_name=(claims.get("name") or "").strip()[:100] or None,
|
||||
email=email or None,
|
||||
password_hash=get_password_hash(secrets.token_urlsafe(48)),
|
||||
is_admin=False,
|
||||
auth_source="sso",
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
# Without this row /api/settings returns 500 for the new user.
|
||||
db.add(models.UserSettings(user_id=user.id))
|
||||
return user
|
||||
|
||||
|
||||
def _attach_identity(db: Session, user: models.User, provider: OIDCProvider,
|
||||
claims: dict, email: str) -> models.OIDCIdentity:
|
||||
identity = models.OIDCIdentity(
|
||||
user_id=user.id,
|
||||
provider_key=provider.key,
|
||||
issuer=claims.get("iss") or provider.issuer,
|
||||
subject=claims["sub"],
|
||||
email=email or None,
|
||||
last_login_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(identity)
|
||||
return identity
|
||||
|
||||
|
||||
def find_identity(db: Session, provider: OIDCProvider,
|
||||
subject: str) -> Optional[models.OIDCIdentity]:
|
||||
return db.query(models.OIDCIdentity).filter(
|
||||
models.OIDCIdentity.provider_key == provider.key,
|
||||
models.OIDCIdentity.subject == subject,
|
||||
).first()
|
||||
|
||||
|
||||
def link_identity(db: Session, user: models.User, provider: OIDCProvider,
|
||||
claims: dict) -> models.OIDCIdentity:
|
||||
"""Attach an SSO identity to an already-authenticated account."""
|
||||
subject = claims.get("sub") or ""
|
||||
if not subject:
|
||||
raise OIDCPolicyError("oidc_no_subject")
|
||||
|
||||
existing = find_identity(db, provider, subject)
|
||||
if existing is not None:
|
||||
if existing.user_id != user.id:
|
||||
raise OIDCPolicyError("oidc_identity_taken")
|
||||
existing.last_login_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return existing
|
||||
|
||||
identity = _attach_identity(db, user, provider, claims, claim_email(claims))
|
||||
db.commit()
|
||||
return identity
|
||||
|
||||
|
||||
def resolve_user(db: Session, provider: OIDCProvider,
|
||||
claims: dict) -> Tuple[models.User, str]:
|
||||
"""Map verified claims onto a user.
|
||||
|
||||
Returns ``(user, action)`` where action is ``login``, ``linked`` or
|
||||
``created``. Raises :class:`OIDCPolicyError` for every refusal.
|
||||
"""
|
||||
subject = claims.get("sub") or ""
|
||||
if not subject:
|
||||
raise OIDCPolicyError("oidc_no_subject")
|
||||
email = claim_email(claims)
|
||||
|
||||
# 1. Known identity — the only automatic path.
|
||||
identity = find_identity(db, provider, subject)
|
||||
if identity is not None:
|
||||
identity.last_login_at = datetime.utcnow()
|
||||
if email:
|
||||
identity.email = email
|
||||
db.commit()
|
||||
return identity.user, "login"
|
||||
|
||||
# 2. An existing local account with the same email.
|
||||
existing = None
|
||||
if email:
|
||||
existing = db.query(models.User).filter(
|
||||
func.lower(models.User.email) == email
|
||||
).first()
|
||||
|
||||
if existing is not None:
|
||||
if not provider.link_by_email:
|
||||
# The account-takeover guard. Linking must be explicit.
|
||||
raise OIDCPolicyError("oidc_account_not_linked")
|
||||
if claims.get("email_verified") is not True:
|
||||
raise OIDCPolicyError("oidc_email_not_verified")
|
||||
logger.warning(
|
||||
"OIDC: linking provider=%s sub=%s to existing user id=%s by email %s",
|
||||
provider.key, subject, existing.id, email,
|
||||
)
|
||||
_attach_identity(db, existing, provider, claims, email)
|
||||
db.commit()
|
||||
return existing, "linked"
|
||||
|
||||
# 3. Just-in-time provisioning.
|
||||
if not provider.allow_signup:
|
||||
raise OIDCPolicyError("oidc_signup_disabled")
|
||||
if not email:
|
||||
raise OIDCPolicyError("oidc_no_email")
|
||||
if provider.allowed_domains:
|
||||
domain = email.rsplit("@", 1)[-1]
|
||||
if domain not in provider.allowed_domains:
|
||||
raise OIDCPolicyError("oidc_domain_not_allowed")
|
||||
|
||||
try:
|
||||
user = _create_user(db, provider, claims, email)
|
||||
_attach_identity(db, user, provider, claims, email)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Race between the uniqueness check and the insert — retry once.
|
||||
db.rollback()
|
||||
try:
|
||||
user = _create_user(db, provider, claims, email)
|
||||
_attach_identity(db, user, provider, claims, email)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise OIDCPolicyError("oidc_provisioning_failed")
|
||||
|
||||
logger.info("OIDC: provisioned user id=%s username=%s via %s",
|
||||
user.id, user.username, provider.key)
|
||||
return user, "created"
|
||||
Reference in New Issue
Block a user