diff --git a/app.py b/app.py
index 88604bc..40ba668 100644
--- a/app.py
+++ b/app.py
@@ -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 = """
-
-Personalisierung
-
-Personalisierung
-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.
-Abmelden
-
-"""
+# --- 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")
diff --git a/migrations/0065_personalisierungen.sql b/migrations/0065_personalisierungen.sql
new file mode 100644
index 0000000..7e39623
--- /dev/null
+++ b/migrations/0065_personalisierungen.sql
@@ -0,0 +1,52 @@
+BEGIN;
+
+-- ADR-0041 (Personalisierung), Schritt 2: Aktivierungscode, Formular,
+-- Bestätigung per Mail. Das Formular liegt in der Kundenplattform, die
+-- Daten hier, weil der Agent sie beim Check-in braucht (Schritt 3).
+
+-- Erlaubte E-Mail-Domänen der Organisation (Ergänzung 15.10.2026). Leer:
+-- jede Adresse ist erlaubt.
+ALTER TABLE organizations
+ ADD COLUMN email_domaenen TEXT[] NOT NULL DEFAULT '{}';
+
+-- Eine Zeile je Aktivierungscode. Ablauf der Stände:
+-- code_offen -> eingereicht -> bestaetigt -> angelegt (Schritt 3)
+-- Abzweige: zurueckgezogen (Kunden-Admin), verfallen (Code oder
+-- Bestätigungsfrist abgelaufen), beendet (Konto entfernt).
+CREATE TABLE personalisierungen (
+ id UUID PRIMARY KEY,
+ device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ status TEXT NOT NULL CHECK (status IN (
+ 'code_offen', 'eingereicht', 'bestaetigt', 'angelegt',
+ 'zurueckgezogen', 'verfallen', 'beendet'
+ )),
+ -- Nur der SHA-256 des normalisierten Codes; der Code selbst wird genau
+ -- einmal angezeigt.
+ code_hash TEXT NOT NULL UNIQUE,
+ code_gueltig_bis TIMESTAMPTZ NOT NULL,
+ -- Kästchen "Bestehenden Zugang behalten" (aus = bisheriges Konto wird
+ -- nach Bestätigung des neuen entfernt).
+ bestehenden_behalten BOOLEAN NOT NULL DEFAULT FALSE,
+ erstellt_von TEXT,
+ erstellt_am TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ email TEXT,
+ anmeldename TEXT,
+ -- SHA-512-crypt; gelöscht, sobald der Agent die Anlage gemeldet hat.
+ passwort_hash TEXT,
+ bestaetigung_token_hash TEXT UNIQUE,
+ bestaetigung_bis TIMESTAMPTZ,
+ eingereicht_am TIMESTAMPTZ,
+ bestaetigt_am TIMESTAMPTZ,
+ angelegt_am TIMESTAMPTZ,
+ beendet_am TIMESTAMPTZ
+);
+
+CREATE INDEX personalisierungen_device_idx ON personalisierungen (device_id, erstellt_am DESC);
+
+-- Höchstens ein laufender Vorgang je Gerät.
+CREATE UNIQUE INDEX personalisierungen_ein_offener_je_geraet
+ ON personalisierungen (device_id)
+ WHERE status IN ('code_offen', 'eingereicht', 'bestaetigt');
+
+COMMIT;