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;