Personalisierung Schritt 2: Aktivierungscode, Einreichen, Bestätigen, E-Mail-Domänen (Migration 0065)

Code je Gerät (7 Tage, nur Hash gespeichert), Einreichen mit Domänen- und
Anmeldenamenprüfung (Passwort kommt nur als SHA-512-crypt), Bestätigung
binnen einer Stunde, verfallene Vorgänge verlieren Hash und Adresse. Die
Platzhalterseite /personalisierung entfällt, das Formular liegt in der
Kundenplattform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-15 15:28:18 +02:00
1 parent 90586094f0
commit 327bf948df
2 files changed
+471 -27

No files matched your search

+419 -27
View File
@@ -23,7 +23,7 @@ from cryptography.fernet import Fernet, InvalidToken
from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse
LOG_PATH = Path("activation.log")
@@ -3806,7 +3806,8 @@ def fetch_organization(organization_id: str):
cur.execute(
"""
SELECT o.id, o.name, o.created_at, o.kontakt_name, o.adresse, o.telefon,
o.kontakt_email, o.notizen, o.ausrollstufe, o.kundengruppe_id, k.name
o.kontakt_email, o.notizen, o.ausrollstufe, o.kundengruppe_id, k.name,
o.email_domaenen
FROM organizations o
LEFT JOIN kundengruppen k ON k.id = o.kundengruppe_id
WHERE o.id = %s
@@ -3830,6 +3831,7 @@ def fetch_organization(organization_id: str):
"ausrollstufe": row[8],
"kundengruppe_id": str(row[9]) if row[9] else None,
"kundengruppe_name": row[10],
"email_domaenen": list(row[11] or []),
}
def create_organization(name: str, kundengruppe_id: str | None = None):
@@ -5822,33 +5824,423 @@ def health():
}
# Startseite des Kiosks im Konto "Personalisierung" (ADR-0041, Rolle
# personalisierung in ansible-content). Bis das Formular mit Aktivierungscode
# steht, nur ein Hinweis. "tuxflotte:abmelden" fängt der Kiosk ab und beendet
# die Sitzung.
PERSONALISIERUNG_SEITE = """<!doctype html>
<html lang="de"><head><meta charset="utf-8">
<title>Personalisierung</title>
<style>
body { font-family: sans-serif; background: #f4f4f4; color: #222;
display: flex; align-items: center; justify-content: center;
height: 100vh; margin: 0; }
main { max-width: 34em; text-align: center; }
a { display: inline-block; margin: 1em .5em; padding: .7em 1.4em;
background: #2a6db0; color: #fff; text-decoration: none; border-radius: 4px; }
</style></head><body><main>
<h1>Personalisierung</h1>
<p>Hier wird bald ein persönliches Konto für dieses Gerät eingerichtet.
Dafür brauchst du einen Aktivierungscode von der Verwaltung deiner
Einrichtung. Die Einrichtung ist noch nicht freigeschaltet.</p>
<a href="tuxflotte:abmelden">Abmelden</a>
</main></body></html>
"""
# --- Personalisierung (ADR-0041, Schritt 2) ----------------------------------
#
# 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.
PERSONALISIERUNG_CODE_ZEICHEN = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
PERSONALISIERUNG_CODE_GUELTIG = timedelta(days=7)
PERSONALISIERUNG_BESTAETIGUNG_FRIST = timedelta(hours=1)
PERSONALISIERUNG_LAUFEND = ("code_offen", "eingereicht", "bestaetigt")
# Anmeldenamen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich,
# beginnend mit einem Buchstaben. Systemkonten prüft die Rolle am Gerät
# zusätzlich gegen /etc/passwd.
PERSONALISIERUNG_ANMELDENAME_MUSTER = re.compile(r"^[a-z][a-z0-9._-]{1,30}$")
PERSONALISIERUNG_RESERVIERT = {
"root", "gast", "personalisierung", "tuxflotte", "admin", "administrator",
"daemon", "bin", "sys", "sync", "games", "man", "lp", "mail", "news", "uucp",
"proxy", "www-data", "backup", "list", "irc", "gnats", "nobody", "messagebus",
"sshd", "lightdm", "gdm", "polkitd", "avahi", "colord", "pulse", "rtkit",
"saned", "syslog", "dnsmasq", "usbmux", "kernoops", "geoclue", "cups-pk-helper",
"speech-dispatcher", "hplip", "fwupd-refresh", "tss", "uuidd", "tcpdump",
"veyon", "ansible", "sudo", "wheel", "users", "nogroup",
}
PERSONALISIERUNG_RESERVIERT_PRAEFIXE = ("systemd-", "tuxflotte", "_")
DOMAIN_MUSTER = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
EMAIL_MUSTER = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@([A-Za-z0-9.-]{1,253})$")
@app.get("/personalisierung", response_class=HTMLResponse)
def personalisierung_seite():
return HTMLResponse(PERSONALISIERUNG_SEITE, headers={"Cache-Control": "no-store"})
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 (
"Der Anmeldename muss mit einem Kleinbuchstaben beginnen und darf nur "
"Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich enthalten (2 bis 31 Zeichen)."
)
if anmeldename in PERSONALISIERUNG_RESERVIERT or anmeldename.startswith(PERSONALISIERUNG_RESERVIERT_PRAEFIXE):
return "Dieser Anmeldename ist reserviert. Bitte einen anderen wählen."
return None
def email_domaenen_normalisieren(domaenen: list[str]) -> tuple[list[str], str | None]:
ergebnis = []
for eintrag in domaenen:
domain = (eintrag or "").strip().lower().lstrip("@").rstrip(".")
if not domain:
continue
if not DOMAIN_MUSTER.match(domain):
return [], f"„{eintrag.strip()}“ ist keine gültige Domäne."
if domain not in ergebnis:
ergebnis.append(domain)
return ergebnis, None
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).
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())
"""
)
PERSONALISIERUNG_SPALTEN = (
"p.id, p.device_id, p.status, p.code_gueltig_bis, p.bestehenden_behalten, p.erstellt_von, "
"p.erstellt_am, p.email, p.anmeldename, p.eingereicht_am, p.bestaetigung_bis, p.bestaetigt_am, "
"p.angelegt_am, p.beendet_am"
)
def personalisierung_als_dict(row) -> dict:
def zeit(wert):
return wert.isoformat() if wert else None
return {
"id": str(row[0]),
"device_id": str(row[1]),
"status": row[2],
"code_gueltig_bis": zeit(row[3]),
"bestehenden_behalten": row[4],
"erstellt_von": row[5],
"erstellt_am": zeit(row[6]),
"email": row[7],
"anmeldename": row[8],
"eingereicht_am": zeit(row[9]),
"bestaetigung_bis": zeit(row[10]),
"bestaetigt_am": zeit(row[11]),
"angelegt_am": zeit(row[12]),
"beendet_am": zeit(row[13]),
}
def fetch_personalisierungen_geraet(device_id: str) -> list[dict]:
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
f"SELECT {PERSONALISIERUNG_SPALTEN} FROM personalisierungen p "
"WHERE p.device_id = %s ORDER BY p.erstellt_am DESC",
(device_id,),
)
return [personalisierung_als_dict(row) for row in cur.fetchall()]
def fetch_personalisierungen_organisation(organization_id: str) -> dict[str, dict]:
"""
Für die Geräteliste: je Gerät der laufende Vorgang und die angelegten
Konten, in einer Abfrage statt einer je Gerät.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
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') "
"ORDER BY p.erstellt_am",
(organization_id,),
)
rows = cur.fetchall()
ergebnis: dict[str, dict] = {}
for row in rows:
eintrag = personalisierung_als_dict(row)
geraet = ergebnis.setdefault(eintrag["device_id"], {"laufend": None, "konten": []})
if eintrag["status"] == "angelegt":
geraet["konten"].append(eintrag)
else:
geraet["laufend"] = eintrag
return ergebnis
def create_personalisierung_code(device_id: str, bestehenden_behalten: bool, erstellt_von: str | None):
code = personalisierung_code_erzeugen()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT organization_id FROM devices WHERE id = %s", (device_id,))
row = cur.fetchone()
if row is None:
return None
personalisierung_verfallene_markieren(cur)
# Ein neuer Code ersetzt einen laufenden Vorgang.
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')
""",
(device_id,),
)
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
""",
(
uuid4(), device_id, row[0], personalisierung_code_hash(code),
datetime.now(timezone.utc) + PERSONALISIERUNG_CODE_GUELTIG,
bestehenden_behalten, erstellt_von,
),
)
gueltig_bis = cur.fetchone()[0]
return {"code": code, "code_gueltig_bis": gueltig_bis.isoformat()}
def withdraw_personalisierung(device_id: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
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')
""",
(device_id,),
)
return cur.rowcount > 0
def submit_personalisierung(code: str, email: 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.
"""
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()
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) + ")."
fehler = anmeldename_fehler(anmeldename)
if fehler:
return None, fehler
cur.execute(
"SELECT 1 FROM personalisierungen WHERE device_id = %s AND status = 'angelegt' AND anmeldename = %s",
(device_id, anmeldename),
)
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
WHERE id = %s
RETURNING bestaetigung_bis
""",
(
email, anmeldename, passwort_hash, personalisierung_token_hash(token),
datetime.now(timezone.utc) + PERSONALISIERUNG_BESTAETIGUNG_FRIST, personalisierung_id,
),
)
bestaetigung_bis = cur.fetchone()[0]
return {
"personalisierung_id": str(personalisierung_id),
"token": token,
"bestaetigung_bis": bestaetigung_bis.isoformat(),
"organisation": org_name,
"hostname": hostname,
}, None
def confirm_personalisierung(token: str):
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)
""",
(personalisierung_token_hash(token),),
)
row = cur.fetchone()
if row is None:
return None
return {"anmeldename": row[0], "hostname": row[1]}
def set_email_domaenen(organization_id: str, domaenen: list[str]) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE organizations SET email_domaenen = %s WHERE id = %s", (domaenen, organization_id))
return cur.rowcount > 0
class CreatePersonalisierungRequest(BaseModel):
bestehenden_behalten: bool = False
erstellt_von: str | None = None
class SubmitPersonalisierungRequest(BaseModel):
code: str
email: str
anmeldename: str
passwort_hash: str
class ConfirmPersonalisierungRequest(BaseModel):
token: str
class SetEmailDomaenenRequest(BaseModel):
domaenen: list[str]
@app.get("/api/v1/devices/{device_id}/personalisierung")
def get_personalisierung_geraet(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "personalisierungen": fetch_personalisierungen_geraet(device_id)}
@app.post("/api/v1/devices/{device_id}/personalisierung")
def post_personalisierung_code(
device_id: str,
payload: CreatePersonalisierungRequest,
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 = 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."}
return {"success": True, **ergebnis}
@app.post("/api/v1/devices/{device_id}/personalisierung/zurueckziehen")
def post_personalisierung_zurueckziehen(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": withdraw_personalisierung(device_id)}
@app.get("/api/v1/organizations/{organization_id}/personalisierungen")
def get_personalisierungen_organisation(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "geraete": fetch_personalisierungen_organisation(organization_id)}
@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 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
)
if fehler:
return {"success": False, "error": "abgelehnt", "message": fehler}
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,
payload: SetEmailDomaenenRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
domaenen, fehler = email_domaenen_normalisieren(payload.domaenen)
if fehler:
return {"success": False, "error": "invalid_request", "message": fehler}
if not set_email_domaenen(organization_id, domaenen):
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True, "domaenen": domaenen}
@app.post("/api/v1/activate")