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
+25 -12
View File
@@ -144,21 +144,34 @@ OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL=https://authentik.example.com/application/o/
Danach `./update.sh` (oder `docker compose --env-file .env up -d`). Danach `./update.sh` (oder `docker compose --env-file .env up -d`).
Ob es geklappt hat, verrät `curl http://<server>:8080/api/auth/oidc/providers`. Ob es geklappt hat, verrät `curl http://<server>:8080/api/auth/oidc/providers`.
**3. Bestehende Konten verknüpfen.** Dafür gibt es zwei Wege: **3. Bestehende Konten verknüpfen.** Beim ersten SSO-Login zu einer noch
unbekannten Kennung fragt Vorrania nach, statt still ein zweites Konto
anzulegen:
*Selbst verknüpfen (empfohlen).* Angemeldet bleiben, in der Seitenleiste unten > Die Anmeldung über Authentik hat geklappt – zu diesem Zugang gibt es hier aber
auf den eigenen Namen klicken → **Mein Konto** → *Verknüpfen*. Das funktioniert > noch kein Konto.
für jeden Benutzer, braucht keine hinterlegte E-Mail-Adresse und keine > **[ Bestehendes Konto verknüpfen ]** oder **[ Neues Konto anlegen ]**
Admin-Rechte: Die Berechtigung stammt aus der laufenden Sitzung. Dort lässt sich
eine Verknüpfung auch wieder lösen – außer es wäre die letzte
Anmeldemöglichkeit des Kontos.
*Über die E-Mail-Adresse.* Trage sie unter *Benutzer* ein – dieselbe wie in Das Verknüpfen verlangt einmalig Benutzername und Passwort des bestehenden
Authentik. Beim ersten SSO-Login wird das Konto dann automatisch gefunden. Kontos. Dieser Nachweis ist der Grund, warum der Anmeldeweg selbst nie fragt
Verknüpft wird nur bei **bestätigter** Adresse. „welches Konto?" – ohne ihn könnte sich jeder mit einem beliebigen Konto beim
Anmeldedienst an ein fremdes Vorrania-Konto hängen. Rolle und Verlauf bleiben
dabei erhalten.
Ohne eines von beidem entsteht beim ersten Anmelden ein **zweites** Konto mit Zwei Wege führen daran vorbei, weil sie das Konto schon vorher finden:
der Rolle *Benutzer*, statt dass das bestehende übernommen wird.
- **E-Mail-Adresse** unter *Benutzer* eintragen – dieselbe wie in Authentik.
Verknüpft wird nur bei **bestätigter** Adresse.
- **Vorab verknüpfen**: angemeldet bleiben, unten in der Seitenleiste auf den
eigenen Namen → **Mein Konto** → *Verknüpfen*. Dort lässt sich eine
Verknüpfung auch wieder lösen – außer es wäre die letzte Anmeldemöglichkeit.
Mit `OIDC_AUTHENTIK_AUTO_CREATE=false` entfällt der Knopf „Neues Konto anlegen";
dann bleibt nur das Verknüpfen mit einem Konto, das der Admin angelegt hat.
Die App zeigt diesen Dialog nicht – dort gilt weiterhin: bekannte Kennung oder
passende Adresse, sonst je nach `AUTO_CREATE` ein neues Konto oder eine Absage.
Einmal im Browser verknüpft, funktioniert die App danach ohne Weiteres.
Weitere Anbieter sind reine Konfiguration: Kürzel in `OIDC_PROVIDERS` ergänzen Weitere Anbieter sind reine Konfiguration: Kürzel in `OIDC_PROVIDERS` ergänzen
und einen zweiten Block mit dessen Präfix anlegen (`OIDC_<KÜRZEL>_…`) – am Code und einen zweiten Block mit dessen Präfix anlegen (`OIDC_<KÜRZEL>_…`) – am Code
+25
View File
@@ -174,6 +174,31 @@ class OidcLoginState(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class OidcPendingLink(Base):
"""Eine gepruefte Anmeldedienst-Kennung, die noch keinem Konto gehoert.
Entsteht, wenn sich jemand erfolgreich beim Anmeldedienst ausgewiesen hat,
aber weder eine bekannte Kennung noch eine passende bestaetigte E-Mail
vorliegt. Statt still ein zweites Konto anzulegen, fragt Vorrania nach:
neues Konto oder bestehendes verknuepfen.
Kurzlebig und einmalig verwendbar. Gespeichert werden nur die geprueften
Angaben aus dem ID-Token – nie das Token selbst.
"""
__tablename__ = "oidc_pending_links"
# Wie bei ApiToken liegt nur der SHA-256-Hash hier; den Klartext kennt allein
# der Browser, der ihn im Adressfragment mitbekommen hat.
code_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
provider: Mapped[str] = mapped_column(String(64), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(255))
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
username_vorschlag: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class UserIdentity(Base): class UserIdentity(Base):
"""Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter. """Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter.
+85 -2
View File
@@ -23,12 +23,14 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import User, UserIdentity from ..models import OidcPendingLink, User, UserIdentity
from ..oidc_config import OidcProvider, provider_finden, registrierte_provider from ..oidc_config import OidcProvider, provider_finden, registrierte_provider
from ..schemas import ( from ..schemas import (
OidcIdentityOut, OidcIdentityOut,
OidcLinkStart, OidcLinkStart,
OidcNativeLogin, OidcNativeLogin,
OidcPendingOut,
OidcPendingVerknuepfen,
OidcProviderOut, OidcProviderOut,
Token, Token,
) )
@@ -48,6 +50,8 @@ WEB_FEHLER = "/login"
# Eine Verknuepfung endet dort, wo sie angestossen wurde – nicht auf der # Eine Verknuepfung endet dort, wo sie angestossen wurde – nicht auf der
# Anmeldemaske: Der Nutzer ist ja die ganze Zeit angemeldet geblieben. # Anmeldemaske: Der Nutzer ist ja die ganze Zeit angemeldet geblieben.
WEB_KONTO = "/konto" WEB_KONTO = "/konto"
# Wohin es geht, wenn die Kennung echt ist, aber noch keinem Konto gehoert.
WEB_ENTSCHEIDUNG = "/sso-neu"
def _provider_oder_404(slug: str) -> OidcProvider: def _provider_oder_404(slug: str) -> OidcProvider:
@@ -189,7 +193,18 @@ def anmeldung_abschliessen(
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
nutzer = dienst.benutzer_aus_claims(db, provider, claims) nutzer = dienst.konto_suchen(db, provider, claims)
if nutzer is None:
# Die Kennung ist echt, gehoert aber noch niemandem. Statt still ein
# zweites Konto anzulegen, nachfragen – wer schon ein Konto hat, will
# meistens genau das verknuepfen.
dienst.offen_aufraeumen(db)
merkzettel = dienst.offen_anlegen(db, provider, claims)
logger.info("SSO '%s': unbekannte Kennung, frage nach.", slug)
return RedirectResponse(
f"{WEB_ENTSCHEIDUNG}#code={quote(merkzettel)}",
status_code=status.HTTP_303_SEE_OTHER,
)
except dienst.OidcFehler as fehler: except dienst.OidcFehler as fehler:
logger.warning("SSO '%s' gescheitert: %s", slug, fehler) logger.warning("SSO '%s' gescheitert: %s", slug, fehler)
return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None) return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None)
@@ -335,3 +350,71 @@ def verknuepfung_loesen(
# "letzte Anmeldemoeglichkeit" ist kein Serverfehler, sondern eine # "letzte Anmeldemoeglichkeit" ist kein Serverfehler, sondern eine
# bewusste Weigerung – der Text erklaert den Ausweg. # bewusste Weigerung – der Text erklaert den Ausweg.
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
# ---------------------------------------------------------------------------
# Erstanmeldung: Kennung ist echt, gehoert aber noch keinem Konto
# ---------------------------------------------------------------------------
def _offen_oder_404(db: Session, code: str) -> OidcPendingLink:
eintrag = dienst.offen_lesen(db, code)
if eintrag is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal anmelden.",
)
return eintrag
@router.get("/pending/{code}", response_model=OidcPendingOut)
def offene_anmeldung(code: str, db: Session = Depends(get_db)) -> OidcPendingOut:
"""Was die Entscheidungsseite anzeigen soll.
Ohne Anmeldung abrufbar – der Einmalcode aus der Adresse ist der Nachweis.
"""
eintrag = _offen_oder_404(db, code)
provider = _provider_oder_404(eintrag.provider)
return OidcPendingOut(
provider=eintrag.provider,
display_name=provider.display_name,
email=eintrag.email,
username_vorschlag=eintrag.username_vorschlag,
anlegen_erlaubt=provider.auto_create,
)
@router.post("/pending/{code}/anlegen", response_model=Token)
def offene_anmeldung_neues_konto(code: str, db: Session = Depends(get_db)) -> Token:
"""Legt ein neues Vorrania-Konto zu dieser Kennung an."""
eintrag = _offen_oder_404(db, code)
provider = _provider_oder_404(eintrag.provider)
try:
nutzer = dienst.offen_neues_konto(db, provider, eintrag)
except dienst.OidcFehler as fehler:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(fehler)) from fehler
logger.info("SSO '%s': neues Konto '%s' auf Wunsch angelegt.", provider.slug, nutzer.username)
return _token_fuer(nutzer)
@router.post("/pending/{code}/verknuepfen", response_model=Token)
def offene_anmeldung_verknuepfen(
code: str, payload: OidcPendingVerknuepfen, db: Session = Depends(get_db)
) -> Token:
"""Haengt die Kennung an ein bestehendes Konto – nach Passwort-Nachweis."""
eintrag = _offen_oder_404(db, code)
provider = _provider_oder_404(eintrag.provider)
try:
nutzer = dienst.offen_verknuepfen(
db, provider, eintrag, payload.username, payload.password
)
except dienst.OidcFehler as fehler:
# Falsches Passwort ist 401, eine belegte Kennung ein Konflikt.
code_http = (
status.HTTP_401_UNAUTHORIZED
if fehler.schluessel == "anmeldung_falsch"
else status.HTTP_409_CONFLICT
)
raise HTTPException(code_http, str(fehler)) from fehler
logger.info("SSO '%s': mit bestehendem Konto '%s' verknuepft.", provider.slug, nutzer.username)
return _token_fuer(nutzer)
+23
View File
@@ -154,6 +154,29 @@ class OidcLinkStart(BaseModel):
authorization_url: str authorization_url: str
class OidcPendingOut(BaseModel):
"""Eine gepruefte Kennung, die noch keinem Konto gehoert.
Wird der Entscheidungsseite gezeigt: „neues Konto" oder „bestehendes
verknuepfen". Der Einmalcode aus der Adresse ist der Nachweis, dass die
Anmeldung beim Anbieter tatsaechlich stattgefunden hat.
"""
provider: str
display_name: str
email: str | None = None
username_vorschlag: str
# Ob dieser Server das Anlegen neuer Konten ueberhaupt zulaesst.
anlegen_erlaubt: bool
class OidcPendingVerknuepfen(BaseModel):
"""Eigentumsnachweis fuer das bestehende Konto."""
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=255)
class OidcNativeLogin(BaseModel): class OidcNativeLogin(BaseModel):
"""ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird.""" """ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird."""
+157 -15
View File
@@ -15,8 +15,9 @@ from __future__ import annotations
import logging import logging
import re import re
import secrets
import time import time
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import httpx import httpx
@@ -27,8 +28,9 @@ from authlib.oidc.core import CodeIDToken
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import Role, User, UserIdentity from ..models import OidcPendingLink, Role, User, UserIdentity
from ..oidc_config import OidcProvider from ..oidc_config import OidcProvider
from ..security import hash_api_token, verify_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,6 +40,8 @@ TIMEOUT_SECONDS = 10.0
CACHE_SEKUNDEN = 3600.0 CACHE_SEKUNDEN = 3600.0
# Etwas Spielraum fuer auseinanderlaufende Uhren zwischen Server und Anbieter. # Etwas Spielraum fuer auseinanderlaufende Uhren zwischen Server und Anbieter.
UHREN_SPIELRAUM = 60 UHREN_SPIELRAUM = 60
# So lange darf die Entscheidung „neues Konto oder verknuepfen" offenbleiben.
OFFEN_GUELTIG = timedelta(minutes=10)
class OidcFehler(Exception): 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) 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] db: Session, provider: OidcProvider, claims: dict[str, Any]
) -> User: ) -> User | None:
"""Findet oder erzeugt das Vorrania-Konto zu einem geprueften ID-Token. """Sucht das Konto zu einem geprueften ID-Token – ohne je eines anzulegen.
Reihenfolge:
1. Bekannte Identitaet (Anbieter + ``sub``) – der Normalfall ab dem zweiten Mal. 1. Bekannte Identitaet (Anbieter + ``sub``) – der Normalfall ab dem zweiten Mal.
2. Erstverknuepfung ueber die **bestaetigte** E-Mail-Adresse. 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() subject = str(claims.get("sub") or "").strip()
if not subject: if not subject:
@@ -428,13 +433,15 @@ def benutzer_aus_claims(
email, email,
) )
# 3) Neu anlegen, falls erlaubt. return None
if not provider.auto_create:
raise OidcFehler(
"kein_konto", def konto_anlegen(db: Session, provider: OidcProvider, claims: dict[str, Any]) -> User:
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss " """Legt ein neues Konto zu einer geprueften, noch freien Kennung an."""
"es zuerst anlegen und die E-Mail-Adresse hinterlegen.", 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 # Die Adresse ist eindeutig indiziert. Sie kann hier trotzdem schon vergeben
# sein – naemlich wenn Schritt 2 sie wegen fehlender Bestaetigung NICHT zum # sein – naemlich wenn Schritt 2 sie wegen fehlender Bestaetigung NICHT zum
@@ -472,3 +479,138 @@ def benutzer_aus_claims(
db.refresh(nutzer) db.refresh(nutzer)
logger.info("SSO '%s': neues Konto '%s' angelegt.", provider.slug, nutzer.username) logger.info("SSO '%s': neues Konto '%s' angelegt.", provider.slug, nutzer.username)
return nutzer 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
+108 -1
View File
@@ -11,16 +11,22 @@ from types import SimpleNamespace
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from app.models import Role, User, UserIdentity from app.models import OidcPendingLink, Role, User, UserIdentity
from app.oidc_config import OidcProvider, provider_aus_umgebung from app.oidc_config import OidcProvider, provider_aus_umgebung
from app.routers.auth import login from app.routers.auth import login
from app.services import oidc_state from app.services import oidc_state
from app.security import hash_password, verify_password from app.security import hash_password, verify_password
from app.services.oidc import ( from app.services.oidc import (
OFFEN_GUELTIG,
OidcFehler, OidcFehler,
benutzer_aus_claims, benutzer_aus_claims,
identitaet_loesen, identitaet_loesen,
identitaet_verknuepfen, identitaet_verknuepfen,
konto_suchen,
offen_anlegen,
offen_lesen,
offen_neues_konto,
offen_verknuepfen,
) )
BASIS = "https://vorrania.example.com" BASIS = "https://vorrania.example.com"
@@ -431,3 +437,104 @@ def test_app_client_darf_eigene_discovery_adresse_haben():
def test_ohne_eigene_adresse_teilen_sich_beide_wege_die_discovery(): def test_ohne_eigene_adresse_teilen_sich_beide_wege_die_discovery():
(p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS) (p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS)
assert p.app_discovery_url == p.discovery_url assert p.app_discovery_url == p.discovery_url
# ---- Erstanmeldung ohne passendes Konto ------------------------------------
def test_unbekannte_kennung_legt_nichts_an(db, provider):
"""konto_suchen darf NIE ein Konto erzeugen – sonst gibt es nichts zu fragen."""
assert konto_suchen(db, provider, claims()) is None
assert db.query(User).count() == 0
assert db.query(UserIdentity).count() == 0
def test_offener_merkzettel_haelt_die_geprueften_angaben(db, provider):
code = offen_anlegen(db, provider, claims(preferred_username="eva.mueller"))
eintrag = offen_lesen(db, code)
assert eintrag.subject == "abc-123"
assert eintrag.email == "eva@example.com"
assert eintrag.email_verified is True
assert eintrag.username_vorschlag == "eva.mueller"
# Der Klartext-Code darf nirgends liegen, nur sein Hash.
assert code not in (eintrag.code_hash, eintrag.subject)
def test_offener_merkzettel_laeuft_ab(db, provider):
code = offen_anlegen(db, provider, claims())
spaeter = datetime.now(timezone.utc) + OFFEN_GUELTIG + timedelta(seconds=1)
assert offen_lesen(db, code, jetzt=spaeter) is None
def test_falscher_code_findet_nichts(db, provider):
offen_anlegen(db, provider, claims())
assert offen_lesen(db, "ausgedacht") is None
def test_verknuepfen_braucht_das_richtige_passwort(db, provider):
"""Das Passwort ist der Eigentumsnachweis – ohne ihn waere der Dialog eine Luecke."""
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.commit()
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims()))
with pytest.raises(OidcFehler) as fehler:
offen_verknuepfen(db, provider, eintrag, "eva", "falsch")
assert fehler.value.schluessel == "anmeldung_falsch"
assert db.query(UserIdentity).count() == 0
# Der Merkzettel bleibt: ein Tippfehler soll nicht den Vorgang wegwerfen.
assert db.query(OidcPendingLink).count() == 1
def test_unbekannter_benutzername_verraet_sich_nicht(db, provider):
"""Gleiche Meldung wie bei falschem Passwort – sonst zaehlt man Konten ab."""
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims()))
with pytest.raises(OidcFehler) as fehler:
offen_verknuepfen(db, provider, eintrag, "gibtsnicht", "beliebig")
assert fehler.value.schluessel == "anmeldung_falsch"
def test_verknuepfen_haengt_kennung_an_das_bestaetigte_konto(db, provider):
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.commit()
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims()))
nutzer = offen_verknuepfen(db, provider, eintrag, "eva", "geheim123")
assert nutzer.id == eva.id
assert nutzer.role == Role.admin # Rolle bleibt erhalten
assert db.query(User).count() == 1 # kein Zweitkonto
assert db.query(OidcPendingLink).count() == 0 # verbraucht
# Ab jetzt meldet dieselbe Kennung direkt an.
assert konto_suchen(db, provider, claims()).id == eva.id
def test_neues_konto_auf_wunsch(db, provider):
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims(preferred_username="eva")))
neu = offen_neues_konto(db, provider, eintrag)
assert neu.username == "eva"
assert neu.role == Role.user
assert neu.password_hash is None
assert db.query(OidcPendingLink).count() == 0
def test_ohne_auto_anlage_bleibt_nur_verknuepfen(db, provider):
"""AUTO_CREATE=false darf sich nicht ueber den Dialog umgehen lassen."""
from dataclasses import replace
ohne = replace(provider, auto_create=False)
eintrag = offen_lesen(db, offen_anlegen(db, ohne, claims()))
with pytest.raises(OidcFehler) as fehler:
offen_neues_konto(db, ohne, eintrag)
assert fehler.value.schluessel == "anlegen_nicht_erlaubt"
assert db.query(User).count() == 0
+3
View File
@@ -7,6 +7,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark"; import BrandMark from "./components/BrandMark";
import Login from "./pages/Login"; import Login from "./pages/Login";
import OidcCallback from "./pages/OidcCallback"; import OidcCallback from "./pages/OidcCallback";
import SsoNeu from "./pages/SsoNeu";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products"; import Products from "./pages/Products";
import ItemList from "./pages/ItemList"; import ItemList from "./pages/ItemList";
@@ -203,6 +204,8 @@ export default function App() {
diesem Moment noch nicht angemeldet) und MUSS vor dem Auffangmuster diesem Moment noch nicht angemeldet) und MUSS vor dem Auffangmuster
unten stehen – sonst schluckt "*" die Weiterleitung samt Token. */} unten stehen – sonst schluckt "*" die Weiterleitung samt Token. */}
<Route path="/auth/callback" element={<OidcCallback />} /> <Route path="/auth/callback" element={<OidcCallback />} />
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
<Route path="/sso-neu" element={<SsoNeu />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
+14 -1
View File
@@ -21,6 +21,8 @@ export function setToken(token) {
// Pfade, bei denen ein 401 NICHT „Sitzung abgelaufen" heisst: beim Anmelden // Pfade, bei denen ein 401 NICHT „Sitzung abgelaufen" heisst: beim Anmelden
// selbst heisst er „Passwort falsch", und die Anbieterliste ist oeffentlich. // selbst heisst er „Passwort falsch", und die Anbieterliste ist oeffentlich.
const OHNE_ABMELDUNG = ["/auth/login", "/auth/oidc/providers"]; const OHNE_ABMELDUNG = ["/auth/login", "/auth/oidc/providers"];
// Beim Verknuepfen einer Erstanmeldung heisst 401 „Passwort falsch".
const OHNE_ABMELDUNG_PRAEFIX = ["/auth/oidc/pending/"];
let beiAbmeldung = null; let beiAbmeldung = null;
export function setUnauthorizedHandler(fn) { export function setUnauthorizedHandler(fn) {
@@ -55,7 +57,9 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite. // Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
// Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch". // Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch".
if (resp.status === 401 && !OHNE_ABMELDUNG.includes(path)) { const ausgenommen = OHNE_ABMELDUNG.includes(path)
|| OHNE_ABMELDUNG_PRAEFIX.some((p) => path.startsWith(p));
if (resp.status === 401 && !ausgenommen) {
beiAbmeldung?.(); beiAbmeldung?.();
} }
@@ -160,6 +164,15 @@ export const api = {
// laufenden Sitzung mitgeht - bei einer Seitennavigation ginge das nicht. // laufenden Sitzung mitgeht - bei einer Seitennavigation ginge das nicht.
oidcLinkStart: (slug) => request(`/auth/oidc/${slug}/link`, { method: "POST" }), oidcLinkStart: (slug) => request(`/auth/oidc/${slug}/link`, { method: "POST" }),
oidcUnlink: (slug) => request(`/auth/oidc/${slug}/link`, { method: "DELETE" }), oidcUnlink: (slug) => request(`/auth/oidc/${slug}/link`, { method: "DELETE" }),
// Erstanmeldung ohne passendes Konto: der Einmalcode aus der Adresse ist der
// Nachweis, dass die Anmeldung beim Dienst stattgefunden hat.
oidcPending: (code) => request(`/auth/oidc/pending/${code}`),
oidcPendingAnlegen: (code) =>
request(`/auth/oidc/pending/${code}/anlegen`, { method: "POST" }),
oidcPendingVerknuepfen: (code, username, password) =>
request(`/auth/oidc/pending/${code}/verknuepfen`, {
method: "POST", body: { username, password },
}),
// Produkte // Produkte
listProducts: (q, categoryId) => { listProducts: (q, categoryId) => {
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import BrandMark from "../components/BrandMark";
/**
* Erstanmeldung über einen Anmeldedienst, zu der es noch kein Vorrania-Konto gibt.
*
* Der Anmeldeweg selbst fragt bewusst nie „welches Konto?" – sonst könnte sich
* jeder mit einem beliebigen Konto beim Anmeldedienst an ein fremdes
* Vorrania-Konto hängen. Hier ist die Frage sicher, weil das Verknüpfen den
* Nachweis über Benutzername und Passwort verlangt.
*
* Der Einmalcode steht im Adressfragment und belegt, dass die Anmeldung beim
* Dienst tatsächlich stattgefunden hat.
*/
export default function SsoNeu() {
const { tokenUebernehmen } = useAuth();
const navigate = useNavigate();
const [code, setCode] = useState(null);
const [info, setInfo] = useState(null);
const [fehler, setFehler] = useState(null);
const [modus, setModus] = useState(null); // null | "verknuepfen"
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const gelesen = useRef(false);
useEffect(() => {
if (gelesen.current) return;
gelesen.current = true;
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const wert = fragment.get("code");
// Sofort aus der Adresszeile räumen, egal wie es weitergeht.
window.history.replaceState(null, "", window.location.pathname);
if (!wert) {
setFehler("Dieser Anmeldevorgang ist nicht mehr gültig.");
return;
}
setCode(wert);
api.oidcPending(wert)
.then((daten) => {
setInfo(daten);
setUsername(daten.username_vorschlag || "");
// Darf gar kein Konto angelegt werden, ist Verknüpfen der einzige Weg.
if (!daten.anlegen_erlaubt) setModus("verknuepfen");
})
.catch((err) => setFehler(err.message));
}, []);
async function anlegen() {
setFehler(null);
setBusy(true);
try {
const token = await api.oidcPendingAnlegen(code);
await tokenUebernehmen(token.access_token);
navigate("/", { replace: true });
} catch (err) {
setFehler(err.message);
setBusy(false);
}
}
async function verknuepfen(e) {
e.preventDefault();
setFehler(null);
setBusy(true);
try {
const token = await api.oidcPendingVerknuepfen(code, username, password);
await tokenUebernehmen(token.access_token);
navigate("/", { replace: true });
} catch (err) {
setFehler(err.message);
setBusy(false);
}
}
if (fehler && !info) {
return (
<div className="login-wrap">
<div className="card login-card">
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
<button className="btn primary" style={{ width: "100%", justifyContent: "center" }}
onClick={() => navigate("/login", { replace: true })}>
Zur Anmeldung
</button>
</div>
</div>
);
}
if (!info) {
return <div className="center muted" style={{ padding: 60 }}>Einen Moment…</div>;
}
return (
<div className="login-wrap">
<div className="card login-card">
<div className="sidebar-brand"><BrandMark /></div>
<div className="alert info">
<Icon name="alert" size={16} />
<div>
Die Anmeldung über <strong>{info.display_name}</strong> hat geklappt
{info.email && <> ({info.email})</>} – zu diesem Zugang gibt es hier
aber noch kein Konto.
</div>
</div>
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
{modus !== "verknuepfen" ? (
<>
<p className="muted small mt-0">
Hast du in Vorrania schon ein Konto, verknüpfe es – dann behältst du
Rolle und Verlauf.
</p>
<button className="btn primary" disabled={busy}
style={{ width: "100%", justifyContent: "center" }}
onClick={() => { setFehler(null); setModus("verknuepfen"); }}>
Bestehendes Konto verknüpfen
</button>
<div className="login-trenner">oder</div>
<button className="btn" disabled={busy}
style={{ width: "100%", justifyContent: "center" }}
onClick={anlegen}>
{busy ? "Wird angelegt…" : `Neues Konto „${info.username_vorschlag}" anlegen`}
</button>
</>
) : (
<form onSubmit={verknuepfen}>
<p className="muted small mt-0">
Melde dich einmal mit deinem bisherigen Benutzernamen und Passwort
an. Danach genügt {info.display_name}.
</p>
<label>
Benutzername
<input value={username} onChange={(e) => setUsername(e.target.value)}
autoFocus autoComplete="username" />
</label>
<label>
Passwort
<input type="password" value={password} autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)} />
</label>
<button className="btn primary" disabled={busy || !username || !password}
style={{ width: "100%", justifyContent: "center" }}>
{busy ? "Wird verknüpft…" : "Verknüpfen und anmelden"}
</button>
{info.anlegen_erlaubt && (
<>
<div className="login-trenner">oder</div>
<button type="button" className="btn" disabled={busy}
style={{ width: "100%", justifyContent: "center" }}
onClick={() => { setFehler(null); setModus(null); }}>
Zurück
</button>
</>
)}
</form>
)}
</div>
</div>
);
}