Personalisierung per Einladung statt Aktivierungscode (ADR-0041, 24.10.2026)
Kunden-Admin trägt die Adresse ein (nur mit Merkmal), der Link (7 Tage, einmalig) führt zu Anmeldename und Passwort; Stand-Seite per abgeleitetem Token. Migration 0072: Stand eingeladen, Code-Spalten optional, laufende Codes zurückgezogen. Code-Endpunkte entfernt; Passwort-vergessen-Kiosk bleibt bis zur Umstellung der Rolle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f6dfd585b
commit
bcfe411290
2 files changed
+204
-164
No files matched your search
@@ -6161,18 +6161,17 @@ def health():
|
||||
}
|
||||
|
||||
|
||||
# --- Personalisierung (ADR-0041, Schritt 2) ----------------------------------
|
||||
# --- Personalisierung (ADR-0041) ---------------------------------------------
|
||||
#
|
||||
# Der Kunden-Admin erzeugt in der Geräte-Info einen Aktivierungscode, am Gerät
|
||||
# löst ihn das Formular der Kundenplattform ein (Kiosk im Konto
|
||||
# "Personalisierung"), die Bestätigung kommt per Mail-Link. Die Kundenplattform
|
||||
# hasht das Passwort, bevor es hierher kommt; Klartext erreicht anode nie.
|
||||
# Angelegt wird das Konto erst in Schritt 3 vom Agent.
|
||||
# Vereinfachter Ablauf (Ergänzung 24.10.2026): Der Kunden-Admin trägt in der
|
||||
# Geräte-Info die Adresse des Benutzers ein, die Kundenplattform schickt einen
|
||||
# Einladungslink. Über den Link wählt der Benutzer Anmeldename und Passwort;
|
||||
# der Link belegt zugleich den Besitz der Adresse. Die Kundenplattform hasht
|
||||
# das Passwort, bevor es hierher kommt; Klartext erreicht anode nie.
|
||||
# Angelegt wird das Konto beim nächsten Start vom Agent.
|
||||
|
||||
PERSONALISIERUNG_CODE_ZEICHEN = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
PERSONALISIERUNG_CODE_GUELTIG = timedelta(days=7)
|
||||
PERSONALISIERUNG_BESTAETIGUNG_FRIST = timedelta(hours=1)
|
||||
PERSONALISIERUNG_LAUFEND = ("code_offen", "eingereicht", "bestaetigt")
|
||||
PERSONALISIERUNG_EINLADUNG_GUELTIG = timedelta(days=7)
|
||||
PERSONALISIERUNG_LAUFEND = ("eingeladen", "bestaetigt")
|
||||
|
||||
# Anmeldenamen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich,
|
||||
# beginnend mit einem Buchstaben. Systemkonten prüft die Rolle am Gerät
|
||||
@@ -6193,23 +6192,10 @@ DOMAIN_MUSTER = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.
|
||||
EMAIL_MUSTER = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@([A-Za-z0-9.-]{1,253})$")
|
||||
|
||||
|
||||
def personalisierung_code_normalisieren(code: str) -> str:
|
||||
return re.sub(r"[\s-]", "", code or "").upper()
|
||||
|
||||
|
||||
def personalisierung_code_hash(code: str) -> str:
|
||||
return hashlib.sha256(personalisierung_code_normalisieren(code).encode()).hexdigest()
|
||||
|
||||
|
||||
def personalisierung_token_hash(token: str) -> str:
|
||||
return hashlib.sha256((token or "").encode()).hexdigest()
|
||||
|
||||
|
||||
def personalisierung_code_erzeugen() -> str:
|
||||
zeichen = "".join(secrets.choice(PERSONALISIERUNG_CODE_ZEICHEN) for _ in range(12))
|
||||
return f"{zeichen[0:4]}-{zeichen[4:8]}-{zeichen[8:12]}"
|
||||
|
||||
|
||||
def anmeldename_fehler(anmeldename: str) -> str | None:
|
||||
if not PERSONALISIERUNG_ANMELDENAME_MUSTER.match(anmeldename):
|
||||
return (
|
||||
@@ -6221,6 +6207,17 @@ def anmeldename_fehler(anmeldename: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def anmeldename_vorschlag(email: str) -> str:
|
||||
"""Vorschlag aus dem Teil vor dem @ (m.hahn@schule.de -> m.hahn), sonst leer."""
|
||||
|
||||
lokal = (email or "").split("@", 1)[0].lower().translate(UMLAUTE)
|
||||
name = re.sub(r"^[^a-z]+", "", re.sub(r"[^a-z0-9._-]", "", lokal))[:31].rstrip("._-")
|
||||
return name if anmeldename_fehler(name) is None else ""
|
||||
|
||||
|
||||
def email_domaenen_normalisieren(domaenen: list[str]) -> tuple[list[str], str | None]:
|
||||
ergebnis = []
|
||||
for eintrag in domaenen:
|
||||
@@ -6235,15 +6232,13 @@ def email_domaenen_normalisieren(domaenen: list[str]) -> tuple[list[str], str |
|
||||
|
||||
|
||||
def personalisierung_verfallene_markieren(cur) -> None:
|
||||
# Lazy statt Zeitgeber: jeder Zugriff räumt zuerst auf. Verfallene
|
||||
# Einreichungen verlieren Passwort-Hash und Adresse (nie bestätigt, also
|
||||
# womöglich ein Tippfehler oder eine fremde Adresse).
|
||||
# Lazy statt Zeitgeber: jeder Zugriff räumt zuerst auf. Die Adresse bleibt
|
||||
# stehen, sie stammt vom Kunden-Admin (Migration 0072).
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE personalisierungen
|
||||
SET status = 'verfallen', passwort_hash = NULL, email = NULL, bestaetigung_token_hash = NULL
|
||||
WHERE (status = 'code_offen' AND code_gueltig_bis < now())
|
||||
OR (status = 'eingereicht' AND bestaetigung_bis < now())
|
||||
SET status = 'verfallen', passwort_hash = NULL, bestaetigung_token_hash = NULL
|
||||
WHERE status = 'eingeladen' AND bestaetigung_bis < now()
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -6251,7 +6246,7 @@ def personalisierung_verfallene_markieren(cur) -> None:
|
||||
# Merkmal "Personalisierung möglich": der Check-in sagt der Rolle in den
|
||||
# Optionen, wofür das Konto "Personalisierung" am Anmeldebildschirm gerade da
|
||||
# ist (ADR-0041, Ergänzungen 18.10.2026):
|
||||
# personalisieren - kein persönliches Konto oder ein Code läuft
|
||||
# personalisieren - kein persönliches Konto oder ein Vorgang läuft
|
||||
# passwort - ein Konto ist eingerichtet: Eintrag "Passwort vergessen?"
|
||||
# aus - nur gesperrte Konten: kein Eintrag
|
||||
PERSONALISIERUNG_MERKMAL = "personalisierung"
|
||||
@@ -6482,9 +6477,9 @@ def fetch_personalisierungen_organisation(organization_id: str) -> dict[str, dic
|
||||
personalisierung_verfallene_markieren(cur)
|
||||
cur.execute(
|
||||
f"SELECT {PERSONALISIERUNG_SPALTEN} FROM personalisierungen p "
|
||||
"WHERE p.organization_id = %s AND p.status IN ('code_offen', 'eingereicht', 'bestaetigt', 'angelegt') "
|
||||
"WHERE p.organization_id = %s AND (p.status = ANY(%s) OR p.status = 'angelegt') "
|
||||
"ORDER BY p.erstellt_am",
|
||||
(organization_id,),
|
||||
(organization_id, list(PERSONALISIERUNG_LAUFEND)),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
@@ -6499,25 +6494,67 @@ def fetch_personalisierungen_organisation(organization_id: str) -> dict[str, dic
|
||||
return ergebnis
|
||||
|
||||
|
||||
def create_personalisierung_code(device_id: str, bestehenden_behalten: bool, erstellt_von: str | None):
|
||||
code = personalisierung_code_erzeugen()
|
||||
def personalisierung_merkmal_aktiv(device_id: str) -> bool:
|
||||
"""Ob das Gerät das Merkmal "Personalisierung möglich" hat (Workspace, OE, Gruppe)."""
|
||||
|
||||
# Die Ansicht der Geräte-Info, nicht fetch_auftragskatalog_state(): Der
|
||||
# Check-in-Weg reichert Optionen an und erzeugt dabei etwa Veyon-Schlüssel.
|
||||
return any(
|
||||
e["merkmal"] == PERSONALISIERUNG_MERKMAL and e["state"] == "present"
|
||||
for e in fetch_auftragskatalog_listing(device_id) or []
|
||||
)
|
||||
|
||||
|
||||
# Die Stand-Seite nach dem Speichern weist sich mit der ID und diesem Token
|
||||
# aus. Abgeleitet statt gespeichert, wie das Kiosk-Token; es zeigt nur den
|
||||
# Stand dieses einen Vorgangs.
|
||||
def personalisierung_stand_token(personalisierung_id) -> str:
|
||||
schluessel = (ENCRYPTION_KEY or "").encode()
|
||||
if not schluessel:
|
||||
return ""
|
||||
return hmac.new(schluessel, f"personalisierung-stand:{personalisierung_id}".encode(), hashlib.sha256).hexdigest()[:32]
|
||||
|
||||
|
||||
def create_personalisierung_einladung(device_id: str, email: str, bestehenden_behalten: bool, erstellt_von: str | None):
|
||||
"""
|
||||
Gibt (ergebnis, fehler) zurück. Das Ergebnis enthält das Token für den
|
||||
Link in der Mail, die kundenplattform verschickt.
|
||||
"""
|
||||
|
||||
if not personalisierung_merkmal_aktiv(device_id):
|
||||
return None, "Das Gerät hat das Merkmal „Personalisierung möglich“ nicht."
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT organization_id FROM devices WHERE id = %s", (device_id,))
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT d.organization_id, d.hostname, o.name, o.email_domaenen
|
||||
FROM devices d JOIN organizations o ON o.id = d.organization_id
|
||||
WHERE d.id = %s
|
||||
""",
|
||||
(device_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return None, "Das Gerät wurde nicht gefunden."
|
||||
organization_id, hostname, org_name, domaenen = row
|
||||
|
||||
treffer = EMAIL_MUSTER.match(email)
|
||||
if not treffer:
|
||||
return None, "Die E-Mail-Adresse ist ungültig."
|
||||
if domaenen and treffer.group(1).lower() not in domaenen:
|
||||
return None, "Erlaubt sind nur Adressen der Einrichtung (" + ", ".join("@" + d for d in domaenen) + ")."
|
||||
|
||||
personalisierung_verfallene_markieren(cur)
|
||||
# Ein neuer Code ersetzt einen laufenden Vorgang.
|
||||
# Eine neue Einladung ersetzt einen laufenden Vorgang; der alte
|
||||
# Link wird damit ungültig.
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE personalisierungen
|
||||
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
|
||||
WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt')
|
||||
WHERE device_id = %s AND status = ANY(%s)
|
||||
""",
|
||||
(device_id,),
|
||||
(device_id, list(PERSONALISIERUNG_LAUFEND)),
|
||||
)
|
||||
# Ohne "Bestehenden Zugang behalten" wird das bisherige Konto
|
||||
# sofort gesperrt; entfernt wird es erst, wenn das neue angelegt
|
||||
@@ -6527,23 +6564,32 @@ def create_personalisierung_code(device_id: str, bestehenden_behalten: bool, ers
|
||||
"UPDATE personalisierungen SET gesperrt = TRUE WHERE device_id = %s AND status = 'angelegt'",
|
||||
(device_id,),
|
||||
)
|
||||
token = secrets.token_urlsafe(32)
|
||||
personalisierung_id = uuid4()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO personalisierungen
|
||||
(id, device_id, organization_id, status, code_hash, code_gueltig_bis,
|
||||
bestehenden_behalten, erstellt_von)
|
||||
VALUES (%s, %s, %s, 'code_offen', %s, %s, %s, %s)
|
||||
RETURNING code_gueltig_bis
|
||||
(id, device_id, organization_id, status, email, bestaetigung_token_hash,
|
||||
bestaetigung_bis, bestehenden_behalten, erstellt_von)
|
||||
VALUES (%s, %s, %s, 'eingeladen', %s, %s, %s, %s, %s)
|
||||
RETURNING bestaetigung_bis
|
||||
""",
|
||||
(
|
||||
uuid4(), device_id, row[0], personalisierung_code_hash(code),
|
||||
datetime.now(timezone.utc) + PERSONALISIERUNG_CODE_GUELTIG,
|
||||
personalisierung_id, device_id, organization_id, email, personalisierung_token_hash(token),
|
||||
datetime.now(timezone.utc) + PERSONALISIERUNG_EINLADUNG_GUELTIG,
|
||||
bestehenden_behalten, erstellt_von,
|
||||
),
|
||||
)
|
||||
gueltig_bis = cur.fetchone()[0]
|
||||
|
||||
return {"code": code, "code_gueltig_bis": gueltig_bis.isoformat()}
|
||||
return {
|
||||
"personalisierung_id": str(personalisierung_id),
|
||||
"token": token,
|
||||
"gueltig_bis": gueltig_bis.isoformat(),
|
||||
"email": email,
|
||||
"hostname": hostname,
|
||||
"organisation": org_name,
|
||||
}, None
|
||||
|
||||
|
||||
def withdraw_personalisierung(device_id: str) -> bool:
|
||||
@@ -6554,70 +6600,52 @@ def withdraw_personalisierung(device_id: str) -> bool:
|
||||
"""
|
||||
UPDATE personalisierungen
|
||||
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
|
||||
WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt')
|
||||
WHERE device_id = %s AND status = ANY(%s)
|
||||
""",
|
||||
(device_id,),
|
||||
(device_id, list(PERSONALISIERUNG_LAUFEND)),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def check_personalisierung_code(code: str):
|
||||
"""
|
||||
Erster Schritt des Formulars: zeigt Einrichtung, Gerät und erlaubte
|
||||
Domänen an, ohne den Code zu verbrauchen.
|
||||
"""
|
||||
def _einladung(cur, token: str, sperren: bool = False):
|
||||
personalisierung_verfallene_markieren(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT p.id, p.device_id, p.email, 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.bestaetigung_token_hash = %s AND p.status = 'eingeladen'
|
||||
""" + (" FOR UPDATE OF p" if sperren else ""),
|
||||
(personalisierung_token_hash(token),),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def einladung_pruefen(token: str) -> dict | None:
|
||||
"""Seite hinter dem Link: zeigt Einrichtung, Gerät und Namensvorschlag, verbraucht nichts."""
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
personalisierung_verfallene_markieren(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT o.name, o.email_domaenen, d.hostname
|
||||
FROM personalisierungen p
|
||||
JOIN organizations o ON o.id = p.organization_id
|
||||
JOIN devices d ON d.id = p.device_id
|
||||
WHERE p.code_hash = %s AND p.status = 'code_offen'
|
||||
""",
|
||||
(personalisierung_code_hash(code),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
row = _einladung(cur, token)
|
||||
if row is None:
|
||||
return None
|
||||
return {"organisation": row[0], "domaenen": list(row[1] or []), "hostname": row[2]}
|
||||
_pid, _device_id, email, hostname, org_name = row
|
||||
return {"email": email, "hostname": hostname, "organisation": org_name, "vorschlag": anmeldename_vorschlag(email)}
|
||||
|
||||
|
||||
def submit_personalisierung(code: str, email: str, anmeldename: str, passwort_hash: str):
|
||||
def einladung_einloesen(token: str, anmeldename: str, passwort_hash: str):
|
||||
"""
|
||||
Gibt (ergebnis, fehler) zurück. Unbekannte, verbrauchte und abgelaufene
|
||||
Codes ergeben dieselbe Meldung - sie verrät nicht, ob ein Code existiert.
|
||||
Links ergeben dieselbe Meldung.
|
||||
"""
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
personalisierung_verfallene_markieren(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT p.id, p.device_id, o.name, o.email_domaenen, d.hostname
|
||||
FROM personalisierungen p
|
||||
JOIN organizations o ON o.id = p.organization_id
|
||||
JOIN devices d ON d.id = p.device_id
|
||||
WHERE p.code_hash = %s AND p.status = 'code_offen'
|
||||
FOR UPDATE OF p
|
||||
""",
|
||||
(personalisierung_code_hash(code),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
row = _einladung(cur, token, sperren=True)
|
||||
if row is None:
|
||||
return None, "Der Aktivierungscode ist ungültig, schon verwendet oder abgelaufen."
|
||||
|
||||
personalisierung_id, device_id, org_name, domaenen, hostname = row
|
||||
|
||||
treffer = EMAIL_MUSTER.match(email)
|
||||
if not treffer:
|
||||
return None, "Die E-Mail-Adresse ist ungültig."
|
||||
if domaenen and treffer.group(1).lower() not in domaenen:
|
||||
return None, "Bitte eine Adresse deiner Einrichtung verwenden (" + ", ".join("@" + d for d in domaenen) + ")."
|
||||
return None, "Der Link ist ungültig, schon verwendet oder abgelaufen."
|
||||
personalisierung_id, device_id, _email, hostname, org_name = row
|
||||
|
||||
fehler = anmeldename_fehler(anmeldename)
|
||||
if fehler:
|
||||
@@ -6630,49 +6658,46 @@ def submit_personalisierung(code: str, email: str, anmeldename: str, passwort_ha
|
||||
if cur.fetchone():
|
||||
return None, "Diesen Anmeldenamen gibt es auf diesem Gerät schon."
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE personalisierungen
|
||||
SET status = 'eingereicht', email = %s, anmeldename = %s, passwort_hash = %s,
|
||||
bestaetigung_token_hash = %s, eingereicht_am = now(), bestaetigung_bis = %s
|
||||
SET status = 'bestaetigt', anmeldename = %s, passwort_hash = %s, bestaetigung_token_hash = NULL,
|
||||
eingereicht_am = now(), bestaetigt_am = now()
|
||||
WHERE id = %s
|
||||
RETURNING bestaetigung_bis
|
||||
""",
|
||||
(
|
||||
email, anmeldename, passwort_hash, personalisierung_token_hash(token),
|
||||
datetime.now(timezone.utc) + PERSONALISIERUNG_BESTAETIGUNG_FRIST, personalisierung_id,
|
||||
),
|
||||
(anmeldename, passwort_hash, personalisierung_id),
|
||||
)
|
||||
bestaetigung_bis = cur.fetchone()[0]
|
||||
|
||||
return {
|
||||
"personalisierung_id": str(personalisierung_id),
|
||||
"token": token,
|
||||
"bestaetigung_bis": bestaetigung_bis.isoformat(),
|
||||
"organisation": org_name,
|
||||
"stand_token": personalisierung_stand_token(personalisierung_id),
|
||||
"anmeldename": anmeldename,
|
||||
"hostname": hostname,
|
||||
"organisation": org_name,
|
||||
}, None
|
||||
|
||||
|
||||
def confirm_personalisierung(token: str):
|
||||
def personalisierung_stand(personalisierung_id: str, token: str) -> dict | None:
|
||||
erwartet = personalisierung_stand_token(personalisierung_id)
|
||||
if not erwartet or not hmac.compare_digest(erwartet, (token or "").strip()):
|
||||
return None
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
personalisierung_verfallene_markieren(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE personalisierungen
|
||||
SET status = 'bestaetigt', bestaetigt_am = now(), bestaetigung_token_hash = NULL
|
||||
WHERE bestaetigung_token_hash = %s AND status = 'eingereicht'
|
||||
RETURNING anmeldename, (SELECT hostname FROM devices WHERE id = device_id)
|
||||
SELECT p.status, p.anmeldename, p.geraet_meldung, 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.id::text = %s
|
||||
""",
|
||||
(personalisierung_token_hash(token),),
|
||||
(personalisierung_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return {"anmeldename": row[0], "hostname": row[1]}
|
||||
status, anmeldename, meldung, hostname, org_name = row
|
||||
return {"status": status, "anmeldename": anmeldename, "geraet_meldung": meldung, "hostname": hostname, "organisation": org_name}
|
||||
|
||||
|
||||
def set_email_domaenen(organization_id: str, domaenen: list[str]) -> bool:
|
||||
@@ -6832,9 +6857,9 @@ def personalisierungen_beenden_bei_neuinstallation(cur, device_id) -> None:
|
||||
UPDATE personalisierungen
|
||||
SET status = 'beendet', beendet_am = now(), passwort_hash = NULL, bestaetigung_token_hash = NULL,
|
||||
reset_passwort_hash = NULL
|
||||
WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt', 'angelegt')
|
||||
WHERE device_id = %s AND (status = ANY(%s) OR status = 'angelegt')
|
||||
""",
|
||||
(device_id,),
|
||||
(device_id, list(PERSONALISIERUNG_LAUFEND)),
|
||||
)
|
||||
|
||||
|
||||
@@ -6862,18 +6887,19 @@ class SetGesperrtRequest(BaseModel):
|
||||
|
||||
|
||||
class CreatePersonalisierungRequest(BaseModel):
|
||||
email: str
|
||||
bestehenden_behalten: bool = False
|
||||
erstellt_von: str | None = None
|
||||
|
||||
|
||||
class SubmitPersonalisierungRequest(BaseModel):
|
||||
code: str
|
||||
email: str
|
||||
anmeldename: str
|
||||
passwort_hash: str
|
||||
class EinladungRequest(BaseModel):
|
||||
token: str
|
||||
anmeldename: str | None = None
|
||||
passwort_hash: str | None = None
|
||||
|
||||
|
||||
class ConfirmPersonalisierungRequest(BaseModel):
|
||||
class StandRequest(BaseModel):
|
||||
id: str
|
||||
token: str
|
||||
|
||||
|
||||
@@ -6900,7 +6926,7 @@ def get_personalisierung_geraet(device_id: str, authorization: str | None = Head
|
||||
|
||||
|
||||
@app.post("/api/v1/devices/{device_id}/personalisierung")
|
||||
def post_personalisierung_code(
|
||||
def post_personalisierung_einladung(
|
||||
device_id: str,
|
||||
payload: CreatePersonalisierungRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
@@ -6908,9 +6934,11 @@ def post_personalisierung_code(
|
||||
if not require_service_token(authorization):
|
||||
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||
|
||||
ergebnis = create_personalisierung_code(device_id, payload.bestehenden_behalten, payload.erstellt_von)
|
||||
if ergebnis is None:
|
||||
return {"success": False, "error": "device_not_found", "message": "Das Gerät wurde nicht gefunden."}
|
||||
ergebnis, fehler = create_personalisierung_einladung(
|
||||
device_id, payload.email.strip(), payload.bestehenden_behalten, payload.erstellt_von
|
||||
)
|
||||
if fehler:
|
||||
return {"success": False, "error": "abgelehnt", "message": fehler}
|
||||
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
@@ -6988,42 +7016,36 @@ def agent_persoenliche_konten(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class CheckPersonalisierungRequest(BaseModel):
|
||||
code: str
|
||||
@app.post("/api/v1/personalisierung/einladung")
|
||||
def post_personalisierung_einladung_link(payload: EinladungRequest, authorization: str | None = Header(default=None)):
|
||||
"""Ohne passwort_hash nur prüfen (Seite hinter dem Link), mit Name und Hash einlösen."""
|
||||
|
||||
|
||||
@app.post("/api/v1/personalisierung/pruefen")
|
||||
def post_personalisierung_pruefen(
|
||||
payload: CheckPersonalisierungRequest,
|
||||
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 = check_personalisierung_code(payload.code)
|
||||
if ergebnis is None:
|
||||
return {"success": False, "error": "abgelehnt", "message": "Der Aktivierungscode ist ungültig, schon verwendet oder abgelaufen."}
|
||||
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
|
||||
@app.post("/api/v1/personalisierung/einreichen")
|
||||
def post_personalisierung_einreichen(
|
||||
payload: SubmitPersonalisierungRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
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 = einladung_pruefen(payload.token)
|
||||
if ergebnis is None:
|
||||
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
if not ROOT_PASSWORD_HASH_PATTERN.match(payload.passwort_hash):
|
||||
return {"success": False, "error": "invalid_request", "message": "Ungültiger Passwort-Hash."}
|
||||
|
||||
ergebnis, fehler = submit_personalisierung(
|
||||
payload.code, payload.email.strip(), payload.anmeldename.strip(), payload.passwort_hash
|
||||
)
|
||||
ergebnis, fehler = einladung_einloesen(payload.token, (payload.anmeldename or "").strip(), payload.passwort_hash)
|
||||
if fehler:
|
||||
return {"success": False, "error": "abgelehnt", "message": fehler}
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
|
||||
@app.post("/api/v1/personalisierung/stand")
|
||||
def post_personalisierung_stand(payload: StandRequest, 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 = personalisierung_stand(payload.id, payload.token)
|
||||
if ergebnis is None:
|
||||
return {"success": False, "error": "ungueltig", "message": "Unbekannter Vorgang."}
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
|
||||
@@ -7067,21 +7089,6 @@ def post_neues_passwort(payload: NeuesPasswortRequest, authorization: str | None
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
|
||||
@app.post("/api/v1/personalisierung/bestaetigen")
|
||||
def post_personalisierung_bestaetigen(
|
||||
payload: ConfirmPersonalisierungRequest,
|
||||
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 = confirm_personalisierung(payload.token)
|
||||
if ergebnis is None:
|
||||
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
|
||||
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
|
||||
@app.put("/api/v1/organizations/{organization_id}/email-domaenen")
|
||||
def put_email_domaenen(
|
||||
organization_id: str,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
BEGIN;
|
||||
|
||||
-- Vereinfachte Personalisierung (ADR-0041, Ergänzung 24.10.2026): Der
|
||||
-- Kunden-Admin trägt die Adresse des Benutzers beim Gerät ein, der Benutzer
|
||||
-- wählt Anmeldename und Passwort über einen Mail-Link. Kein Aktivierungscode
|
||||
-- und keine eigene Bestätigung mehr; der Link belegt den Besitz der Adresse.
|
||||
--
|
||||
-- Neuer Ablauf der Stände:
|
||||
-- eingeladen -> bestaetigt -> angelegt
|
||||
-- Der Link nutzt bestaetigung_token_hash/bestaetigung_bis (7 Tage). Die
|
||||
-- Adresse bleibt beim Verfall erhalten: Sie stammt vom Kunden-Admin, nicht
|
||||
-- von einer unbestätigten Eingabe.
|
||||
|
||||
ALTER TABLE personalisierungen DROP CONSTRAINT personalisierungen_status_check;
|
||||
ALTER TABLE personalisierungen ADD CONSTRAINT personalisierungen_status_check CHECK (status IN (
|
||||
'eingeladen', 'code_offen', 'eingereicht', 'bestaetigt', 'angelegt',
|
||||
'zurueckgezogen', 'verfallen', 'fehlgeschlagen', 'beendet'
|
||||
));
|
||||
|
||||
ALTER TABLE personalisierungen ALTER COLUMN code_hash DROP NOT NULL;
|
||||
ALTER TABLE personalisierungen ALTER COLUMN code_gueltig_bis DROP NOT NULL;
|
||||
|
||||
-- Laufende Vorgänge aus dem Code-Weg gibt es nach dem Umbau nicht mehr.
|
||||
UPDATE personalisierungen
|
||||
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
|
||||
WHERE status IN ('code_offen', 'eingereicht');
|
||||
|
||||
DROP INDEX personalisierungen_ein_offener_je_geraet;
|
||||
CREATE UNIQUE INDEX personalisierungen_ein_offener_je_geraet
|
||||
ON personalisierungen (device_id)
|
||||
WHERE status IN ('eingeladen', 'code_offen', 'eingereicht', 'bestaetigt');
|
||||
|
||||
COMMIT;
|
||||
Reference in new issue
Block a user