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:
co-authored by
Claude Opus 5
parent
a134b2aad5
commit
eb29143f97
+157
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user