Personalisierung: Passwort vergessen per Mail-Link, Modus im Check-in, Admin-Reset ohne Änderungszwang (Migration 0067, ADR-0041 18.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d889b9a61
commit
46b875ea7e
2 files changed
+234
-10
No files matched your search
@@ -1602,7 +1602,15 @@ def fetch_auftragskatalog_state(device_id: str):
|
|||||||
if key == VEYON_MERKMAL and aktiv:
|
if key == VEYON_MERKMAL and aktiv:
|
||||||
optionen = veyon_optionen_anreichern(device_id, merkmal_id, optionen)
|
optionen = veyon_optionen_anreichern(device_id, merkmal_id, optionen)
|
||||||
if key == PERSONALISIERUNG_MERKMAL and aktiv:
|
if key == PERSONALISIERUNG_MERKMAL and aktiv:
|
||||||
optionen = {**optionen, "anmeldung_anbieten": personalisierung_anmeldung_anbieten(device_id)}
|
modus = personalisierung_modus(device_id)
|
||||||
|
optionen = {
|
||||||
|
**optionen,
|
||||||
|
"modus": modus,
|
||||||
|
# Für Rollenstände vor "modus" (18.10.2026).
|
||||||
|
"anmeldung_anbieten": modus == "personalisieren",
|
||||||
|
"geraet": str(device_id),
|
||||||
|
"kiosk_token": personalisierung_kiosk_token(device_id),
|
||||||
|
}
|
||||||
|
|
||||||
ergebnis.append({
|
ergebnis.append({
|
||||||
"merkmal": key,
|
"merkmal": key,
|
||||||
@@ -5915,14 +5923,15 @@ def personalisierung_verfallene_markieren(cur) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# Merkmal "Personalisierung möglich": der Check-in sagt der Rolle in den
|
# Merkmal "Personalisierung möglich": der Check-in sagt der Rolle in den
|
||||||
# Optionen, ob das Konto "Personalisierung" gerade angeboten wird (ADR-0041,
|
# Optionen, wofür das Konto "Personalisierung" am Anmeldebildschirm gerade da
|
||||||
# Ergänzung 18.10.2026). Ein persönliches Konto pro Gerät: steht eins und
|
# ist (ADR-0041, Ergänzungen 18.10.2026):
|
||||||
# läuft kein Code, verschwindet der Eintrag vom Anmeldebildschirm; ein neuer
|
# personalisieren - kein persönliches Konto oder ein Code läuft
|
||||||
# Code der Verwaltung holt ihn zurück.
|
# passwort - ein Konto ist eingerichtet: Eintrag "Passwort vergessen?"
|
||||||
|
# aus - nur gesperrte Konten: kein Eintrag
|
||||||
PERSONALISIERUNG_MERKMAL = "personalisierung"
|
PERSONALISIERUNG_MERKMAL = "personalisierung"
|
||||||
|
|
||||||
|
|
||||||
def personalisierung_anmeldung_anbieten(device_id: str) -> bool:
|
def personalisierung_modus(device_id: str) -> str:
|
||||||
with get_database_connection() as conn:
|
with get_database_connection() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
personalisierung_verfallene_markieren(cur)
|
personalisierung_verfallene_markieren(cur)
|
||||||
@@ -5930,14 +5939,159 @@ def personalisierung_anmeldung_anbieten(device_id: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
bool_or(status = ANY(%s)),
|
bool_or(status = ANY(%s)),
|
||||||
bool_or(status = 'angelegt' AND NOT entfernen)
|
bool_or(status = 'angelegt' AND NOT entfernen),
|
||||||
|
bool_or(status = 'angelegt' AND NOT entfernen AND NOT gesperrt)
|
||||||
FROM personalisierungen
|
FROM personalisierungen
|
||||||
WHERE device_id = %s
|
WHERE device_id = %s
|
||||||
""",
|
""",
|
||||||
(list(PERSONALISIERUNG_LAUFEND), device_id),
|
(list(PERSONALISIERUNG_LAUFEND), device_id),
|
||||||
)
|
)
|
||||||
laufend, konto_vorhanden = cur.fetchone()
|
laufend, konto_vorhanden, konto_aktiv = cur.fetchone()
|
||||||
return bool(laufend) or not konto_vorhanden
|
if laufend or not konto_vorhanden:
|
||||||
|
return "personalisieren"
|
||||||
|
return "passwort" if konto_aktiv else "aus"
|
||||||
|
|
||||||
|
|
||||||
|
# Der Kiosk "Passwort vergessen?" weist sich mit Gerät und diesem Token aus.
|
||||||
|
# Abgeleitet statt gespeichert; lesen kann es jedes lokale Konto des Geräts,
|
||||||
|
# es erlaubt aber nur, dem Besitzer einen Link an seine eigene Adresse zu
|
||||||
|
# schicken (begrenzt, siehe VERGESSEN_*).
|
||||||
|
def personalisierung_kiosk_token(device_id) -> str:
|
||||||
|
schluessel = (ENCRYPTION_KEY or "").encode()
|
||||||
|
if not schluessel:
|
||||||
|
return ""
|
||||||
|
return hmac.new(schluessel, f"personalisierung-kiosk:{device_id}".encode(), hashlib.sha256).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
VERGESSEN_LINK_GUELTIG = timedelta(hours=1)
|
||||||
|
VERGESSEN_ABSTAND = timedelta(minutes=10)
|
||||||
|
VERGESSEN_JE_TAG = 3
|
||||||
|
|
||||||
|
|
||||||
|
def email_maskieren(email: str | None) -> str:
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return ""
|
||||||
|
lokal, domain = email.rsplit("@", 1)
|
||||||
|
return f"{lokal[:1]}•••@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def _vergessen_konto(cur, device_id: str, token: str):
|
||||||
|
"""Das aktive persönliche Konto des Geräts, wenn das Kiosk-Token passt."""
|
||||||
|
|
||||||
|
erwartet = personalisierung_kiosk_token(device_id)
|
||||||
|
if not erwartet or not hmac.compare_digest(erwartet, (token or "").strip()):
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.id, p.anmeldename, p.email, p.vergessen_mails, d.hostname, o.name
|
||||||
|
FROM personalisierungen p
|
||||||
|
JOIN devices d ON d.id = p.device_id
|
||||||
|
JOIN organizations o ON o.id = p.organization_id
|
||||||
|
WHERE p.device_id::text = %s AND p.status = 'angelegt' AND NOT p.entfernen AND NOT p.gesperrt
|
||||||
|
ORDER BY p.angelegt_am DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(str(device_id),),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def vergessen_pruefen(device_id: str, token: str) -> dict | None:
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
row = _vergessen_konto(cur, device_id, token)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
_pid, anmeldename, email, _mails, hostname, org_name = row
|
||||||
|
return {"anmeldename": anmeldename, "email_maskiert": email_maskieren(email), "hostname": hostname, "organisation": org_name}
|
||||||
|
|
||||||
|
|
||||||
|
def vergessen_anfordern(device_id: str, token: str):
|
||||||
|
"""
|
||||||
|
Erzeugt den Link für "Passwort vergessen?". Gibt (Ergebnis, Fehler)
|
||||||
|
zurück; das Ergebnis enthält die volle Adresse und das Token für die
|
||||||
|
Mail, die kundenplattform verschickt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
jetzt = datetime.now(timezone.utc)
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
row = _vergessen_konto(cur, device_id, token)
|
||||||
|
if row is None:
|
||||||
|
return None, "Auf diesem Gerät ist kein persönliches Konto eingerichtet."
|
||||||
|
pid, anmeldename, email, mails, hostname, org_name = row
|
||||||
|
cur.execute("SELECT 1 FROM personalisierungen WHERE id = %s FOR UPDATE", (pid,))
|
||||||
|
|
||||||
|
letzte = [m for m in (mails or []) if m > jetzt - timedelta(days=1)]
|
||||||
|
if letzte and max(letzte) > jetzt - VERGESSEN_ABSTAND:
|
||||||
|
return None, "Es wurde gerade erst ein Link verschickt. Bitte im Postfach (auch im Spam-Ordner) nachsehen oder in 10 Minuten erneut versuchen."
|
||||||
|
if len(letzte) >= VERGESSEN_JE_TAG:
|
||||||
|
return None, "Heute wurden schon mehrere Links verschickt. Bitte morgen erneut versuchen oder an die Verwaltung wenden."
|
||||||
|
|
||||||
|
link_token = secrets.token_urlsafe(32)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE personalisierungen
|
||||||
|
SET vergessen_token_hash = %s, vergessen_bis = %s, vergessen_mails = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(personalisierung_token_hash(link_token), jetzt + VERGESSEN_LINK_GUELTIG, letzte + [jetzt], pid),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"personalisierung_id": str(pid),
|
||||||
|
"token": link_token,
|
||||||
|
"email": email,
|
||||||
|
"email_maskiert": email_maskieren(email),
|
||||||
|
"anmeldename": anmeldename,
|
||||||
|
"hostname": hostname,
|
||||||
|
"organisation": org_name,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def _neues_passwort_konto(cur, token: str):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.id, p.anmeldename, d.hostname
|
||||||
|
FROM personalisierungen p
|
||||||
|
JOIN devices d ON d.id = p.device_id
|
||||||
|
WHERE p.vergessen_token_hash = %s AND p.vergessen_bis > now()
|
||||||
|
AND p.status = 'angelegt' AND NOT p.entfernen AND NOT p.gesperrt
|
||||||
|
""",
|
||||||
|
(personalisierung_token_hash(token),),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def neues_passwort_pruefen(token: str) -> dict | None:
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
row = _neues_passwort_konto(cur, token)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {"anmeldename": row[1], "hostname": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
def neues_passwort_setzen(token: str, passwort_hash: str) -> dict | None:
|
||||||
|
"""Wie der Reset der Verwaltung, nur ohne Änderungszwang; der Link ist danach verbraucht."""
|
||||||
|
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
row = _neues_passwort_konto(cur, token)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
pid, anmeldename, hostname = row
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE personalisierungen
|
||||||
|
SET reset_id = %s, reset_passwort_hash = %s, reset_aendern = FALSE,
|
||||||
|
reset_angefordert_am = now(), reset_angefordert_von = 'selbst (Mail-Link)',
|
||||||
|
reset_gesetzt_am = NULL, vergessen_token_hash = NULL, vergessen_bis = NULL
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(uuid4(), passwort_hash, pid),
|
||||||
|
)
|
||||||
|
return {"anmeldename": anmeldename, "hostname": hostname}
|
||||||
|
|
||||||
|
|
||||||
PERSONALISIERUNG_SPALTEN = (
|
PERSONALISIERUNG_SPALTEN = (
|
||||||
@@ -6368,7 +6522,7 @@ class PersoenlichesKontoErgebnis(BaseModel):
|
|||||||
|
|
||||||
class PasswortResetRequest(BaseModel):
|
class PasswortResetRequest(BaseModel):
|
||||||
passwort_hash: str
|
passwort_hash: str
|
||||||
aendern: bool = True
|
aendern: bool = False
|
||||||
angefordert_von: str | None = None
|
angefordert_von: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -6397,6 +6551,16 @@ class ConfirmPersonalisierungRequest(BaseModel):
|
|||||||
token: str
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class VergessenRequest(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class NeuesPasswortRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
passwort_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SetEmailDomaenenRequest(BaseModel):
|
class SetEmailDomaenenRequest(BaseModel):
|
||||||
domaenen: list[str]
|
domaenen: list[str]
|
||||||
|
|
||||||
@@ -6537,6 +6701,46 @@ def post_personalisierung_einreichen(
|
|||||||
return {"success": True, **ergebnis}
|
return {"success": True, **ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/personalisierung/vergessen/pruefen")
|
||||||
|
def post_vergessen_pruefen(payload: VergessenRequest, authorization: str | None = Header(default=None)):
|
||||||
|
if not require_service_token(authorization):
|
||||||
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||||
|
|
||||||
|
ergebnis = vergessen_pruefen(payload.device_id, payload.token)
|
||||||
|
if ergebnis is None:
|
||||||
|
return {"success": False, "error": "kein_konto", "message": "Auf diesem Gerät ist kein persönliches Konto eingerichtet."}
|
||||||
|
return {"success": True, **ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/personalisierung/vergessen/anfordern")
|
||||||
|
def post_vergessen_anfordern(payload: VergessenRequest, authorization: str | None = Header(default=None)):
|
||||||
|
if not require_service_token(authorization):
|
||||||
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||||
|
|
||||||
|
ergebnis, fehler = vergessen_anfordern(payload.device_id, payload.token)
|
||||||
|
if ergebnis is None:
|
||||||
|
return {"success": False, "error": "abgelehnt", "message": fehler}
|
||||||
|
return {"success": True, **ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/personalisierung/neues-passwort")
|
||||||
|
def post_neues_passwort(payload: NeuesPasswortRequest, authorization: str | None = Header(default=None)):
|
||||||
|
"""Ohne passwort_hash nur prüfen (Seite hinter dem Link), mit Hash setzen."""
|
||||||
|
|
||||||
|
if not require_service_token(authorization):
|
||||||
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||||
|
|
||||||
|
if payload.passwort_hash is None:
|
||||||
|
ergebnis = neues_passwort_pruefen(payload.token)
|
||||||
|
elif not payload.passwort_hash.startswith("$6$"):
|
||||||
|
return {"success": False, "error": "invalid_request", "message": "Kein SHA-512-crypt-Hash."}
|
||||||
|
else:
|
||||||
|
ergebnis = neues_passwort_setzen(payload.token, payload.passwort_hash)
|
||||||
|
if ergebnis is None:
|
||||||
|
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
|
||||||
|
return {"success": True, **ergebnis}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/personalisierung/bestaetigen")
|
@app.post("/api/v1/personalisierung/bestaetigen")
|
||||||
def post_personalisierung_bestaetigen(
|
def post_personalisierung_bestaetigen(
|
||||||
payload: ConfirmPersonalisierungRequest,
|
payload: ConfirmPersonalisierungRequest,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- "Passwort vergessen?" am Anmeldebildschirm (ADR-0041, Ergänzung
|
||||||
|
-- 18.10.2026). Nach der Personalisierung zeigt der Eintrag am Gerät diesen
|
||||||
|
-- Namen und öffnet im Kiosk eine Seite, die einen Link an die hinterlegte
|
||||||
|
-- Adresse schickt. Über den Link setzt die Person ein neues Passwort; es
|
||||||
|
-- läuft dann wie der Reset der Verwaltung über reset_* (Migration 0066).
|
||||||
|
|
||||||
|
ALTER TABLE personalisierungen
|
||||||
|
-- Einmal-Token des Links (nur Hash) und seine Gültigkeit.
|
||||||
|
ADD COLUMN vergessen_token_hash TEXT,
|
||||||
|
ADD COLUMN vergessen_bis TIMESTAMPTZ,
|
||||||
|
-- Versandzeitpunkte der letzten 24 Stunden, für die Begrenzung.
|
||||||
|
ADD COLUMN vergessen_mails TIMESTAMPTZ[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX personalisierungen_vergessen_token
|
||||||
|
ON personalisierungen (vergessen_token_hash)
|
||||||
|
WHERE vergessen_token_hash IS NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in new issue
Block a user