Erstanmeldung fragt nach, statt still ein zweites Konto anzulegen

Wer sich zum ersten Mal ueber den Anmeldedienst anmeldet und hier noch kein
Konto hat, bekam bisher wortlos ein neues - mit Rolle "Benutzer" und ohne
Verlauf. Wer schon ein Konto hatte, wollte aber fast immer genau das verknuepfen
und stand dann mit zwei Konten da.

Jetzt zeigt der Rueckweg eine Entscheidungsseite: bestehendes Konto verknuepfen
oder ein neues anlegen. Das Verknuepfen verlangt einmalig Benutzername und
Passwort des bestehenden Kontos. Genau dieser Nachweis ist der Grund, warum der
Anmeldeweg selbst nie fragen darf, welches Konto gemeint ist - ohne ihn koennte
sich jeder mit einem beliebigen Konto beim Anmeldedienst an ein fremdes
Vorrania-Konto haengen.

Die geprueften Angaben liegen dafuer kurzlebig und einmalig in
oidc_pending_links; vom Code steht dort nur der SHA-256-Hash, wie bei den
API-Tokens. Ein falsch getipptes Passwort verbraucht den Vorgang nicht, und
unbekannter Benutzername und falsches Passwort antworten gleich - sonst liessen
sich vorhandene Konten abzaehlen.

AUTO_CREATE=false blendet den Knopf zum Anlegen aus und laesst sich auch ueber
den Dialog nicht umgehen. Der App-Weg bleibt unveraendert, weil er keinen
Zwischenschritt anzeigen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 22:28:38 +02:00
co-authored by Claude Opus 5
parent a134b2aad5
commit eb29143f97
9 changed files with 609 additions and 31 deletions
+157 -15
View File
@@ -15,8 +15,9 @@ from __future__ import annotations
import logging
import re
import secrets
import time
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
@@ -27,8 +28,9 @@ from authlib.oidc.core import CodeIDToken
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import Role, User, UserIdentity
from ..models import OidcPendingLink, Role, User, UserIdentity
from ..oidc_config import OidcProvider
from ..security import hash_api_token, verify_password
logger = logging.getLogger(__name__)
@@ -38,6 +40,8 @@ TIMEOUT_SECONDS = 10.0
CACHE_SEKUNDEN = 3600.0
# Etwas Spielraum fuer auseinanderlaufende Uhren zwischen Server und Anbieter.
UHREN_SPIELRAUM = 60
# So lange darf die Entscheidung „neues Konto oder verknuepfen" offenbleiben.
OFFEN_GUELTIG = timedelta(minutes=10)
class OidcFehler(Exception):
@@ -345,16 +349,17 @@ def identitaet_loesen(db: Session, nutzer: User, provider_slug: str) -> None:
logger.info("SSO '%s': Verknuepfung von '%s' geloest.", provider_slug, nutzer.username)
def benutzer_aus_claims(
def konto_suchen(
db: Session, provider: OidcProvider, claims: dict[str, Any]
) -> User:
"""Findet oder erzeugt das Vorrania-Konto zu einem geprueften ID-Token.
Reihenfolge:
) -> User | None:
"""Sucht das Konto zu einem geprueften ID-Token – ohne je eines anzulegen.
1. Bekannte Identitaet (Anbieter + ``sub``) – der Normalfall ab dem zweiten Mal.
2. Erstverknuepfung ueber die **bestaetigte** E-Mail-Adresse.
3. Neues Konto, falls fuer den Anbieter erlaubt.
``None`` heisst: die Kennung ist echt, gehoert aber noch niemandem. Der
Browser-Weg fragt dann nach (neues Konto oder bestehendes verknuepfen),
statt still ein zweites Konto anzulegen.
"""
subject = str(claims.get("sub") or "").strip()
if not subject:
@@ -428,13 +433,15 @@ def benutzer_aus_claims(
email,
)
# 3) Neu anlegen, falls erlaubt.
if not provider.auto_create:
raise OidcFehler(
"kein_konto",
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss "
"es zuerst anlegen und die E-Mail-Adresse hinterlegen.",
)
return None
def konto_anlegen(db: Session, provider: OidcProvider, claims: dict[str, Any]) -> User:
"""Legt ein neues Konto zu einer geprueften, noch freien Kennung an."""
subject = str(claims.get("sub") or "").strip()
if not subject:
raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.")
email = _email_aus_claims(claims)
# Die Adresse ist eindeutig indiziert. Sie kann hier trotzdem schon vergeben
# sein – naemlich wenn Schritt 2 sie wegen fehlender Bestaetigung NICHT zum
@@ -472,3 +479,138 @@ def benutzer_aus_claims(
db.refresh(nutzer)
logger.info("SSO '%s': neues Konto '%s' angelegt.", provider.slug, nutzer.username)
return nutzer
def benutzer_aus_claims(
db: Session, provider: OidcProvider, claims: dict[str, Any]
) -> User:
"""Sucht das Konto und legt es notfalls an – der Weg ohne Rueckfrage.
Genutzt vom App-Anmeldeweg, der keinen Zwischenschritt anzeigen kann.
"""
nutzer = konto_suchen(db, provider, claims)
if nutzer is not None:
return nutzer
if not provider.auto_create:
raise OidcFehler(
"kein_konto",
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Melde dich einmal im "
"Browser an, um es zu verknuepfen, oder lass dir eines anlegen.",
)
return konto_anlegen(db, provider, claims)
# ---------------------------------------------------------------------------
# Offene Erstanmeldung: Kennung ist echt, gehoert aber noch keinem Konto
# ---------------------------------------------------------------------------
def _mit_zeitzone(wert: datetime) -> datetime:
# SQLite gibt Zeitstempel ohne Zonenangabe zurueck.
return wert if wert.tzinfo is not None else wert.replace(tzinfo=timezone.utc)
def offen_anlegen(db: Session, provider: OidcProvider, claims: dict[str, Any]) -> str:
"""Merkt die geprueften Angaben vor und gibt den Einmalcode im Klartext zurueck.
Gespeichert wird nur, was aus dem bereits geprueften ID-Token stammt – nie
das Token selbst. Vom Code liegt hier nur der Hash, wie bei den API-Tokens.
"""
subject = str(claims.get("sub") or "").strip()
if not subject:
raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.")
roh_bestaetigt = claims.get("email_verified")
code = secrets.token_urlsafe(32)
db.add(
OidcPendingLink(
code_hash=hash_api_token(code),
provider=provider.slug,
subject=subject,
email=_email_aus_claims(claims),
email_verified=roh_bestaetigt is True or str(roh_bestaetigt).lower() == "true",
username_vorschlag=_namensvorschlag(claims),
created_at=_jetzt(),
)
)
db.commit()
return code
def offen_lesen(
db: Session, code: str, jetzt: datetime | None = None
) -> OidcPendingLink | None:
"""Liest den Merkzettel, ohne ihn zu verbrauchen (fuer die Anzeige)."""
eintrag = db.get(OidcPendingLink, hash_api_token(code))
if eintrag is None:
return None
if _mit_zeitzone(eintrag.created_at) + OFFEN_GUELTIG < (jetzt or _jetzt()):
db.delete(eintrag)
db.commit()
return None
return eintrag
def offen_aufraeumen(db: Session, jetzt: datetime | None = None) -> int:
grenze = (jetzt or _jetzt()) - OFFEN_GUELTIG
alte = [
e for e in db.query(OidcPendingLink).all() if _mit_zeitzone(e.created_at) < grenze
]
for eintrag in alte:
db.delete(eintrag)
if alte:
db.commit()
return len(alte)
def _claims_aus_offen(eintrag: OidcPendingLink) -> dict[str, Any]:
return {
"sub": eintrag.subject,
"email": eintrag.email,
"email_verified": eintrag.email_verified,
"preferred_username": eintrag.username_vorschlag,
}
def offen_neues_konto(
db: Session, provider: OidcProvider, eintrag: OidcPendingLink
) -> User:
"""Legt das neue Konto an und verbraucht den Merkzettel."""
if not provider.auto_create:
raise OidcFehler(
"anlegen_nicht_erlaubt",
"Auf diesem Server duerfen sich keine neuen Konten selbst anlegen.",
)
claims = _claims_aus_offen(eintrag)
db.delete(eintrag)
db.flush()
return konto_anlegen(db, provider, claims)
def offen_verknuepfen(
db: Session,
provider: OidcProvider,
eintrag: OidcPendingLink,
username: str,
passwort: str,
) -> User:
"""Haengt die Kennung an ein bestehendes Konto – nach Passwort-Nachweis.
Das Passwort ist hier der Eigentumsnachweis. Ohne ihn koennte sich jeder mit
einem beliebigen Konto beim Anmeldedienst an ein fremdes Vorrania-Konto
haengen – genau deshalb fragt der Anmeldeweg selbst nie „welches Konto?".
"""
nutzer = db.query(User).filter(User.username == username).first()
if nutzer is None or not verify_password(passwort, nutzer.password_hash):
# Bewusst dieselbe Meldung fuer „gibt es nicht" und „Passwort falsch":
# sonst verraet die Antwort, welche Benutzernamen existieren.
raise OidcFehler("anmeldung_falsch", "Benutzername oder Passwort falsch.")
claims = _claims_aus_offen(eintrag)
identitaet_verknuepfen(db, nutzer, provider, claims)
# Erst nach erfolgreicher Verknuepfung verbrauchen – ein Tippfehler im
# Passwort soll nicht den ganzen Anmeldevorgang wegwerfen.
db.delete(eintrag)
db.commit()
db.refresh(nutzer)
return nutzer