""" API-Tokens mit granularem Scope (siehe app/db/migrations/0005_api_tokens.sql). Format: "jht_<43 zeichen urlsicherer Zufalls-String>" (secrets.token_urlsafe(32)). Wie Recovery-Codes (app/security/totp.py) wird nur ein gepfefferter SHA-256-Hash gespeichert -- das Klartext-Token existiert nur einmalig im Response-Body der Erzeugung und ist danach serverseitig nicht mehr rekonstruierbar. Scope-Modell: ":" mit aktion in {read, write}. "write" impliziert automatisch "read" auf derselben Ressource (siehe token_has_scope). """ from __future__ import annotations import hashlib import secrets TOKEN_PREFIX = "jht_" _TOKEN_PEPPER = b"api_token_pepper" SCOPE_RESOURCES = ( "users", "host_groups", "hosts", "ssh_keys", # Migration 0012: RDP-Zugangsdaten sind jetzt eigenstaendige, # wiederverwendbare Objekte (analog ssh_keys) statt eines 1:1-Datensatzes # am Host -- verdienen deshalb aus demselben Grund wie ssh_keys einen # eigenen Scope statt ueber "hosts" mitzulaufen. "rdp_credentials", "roles", "user_groups", "audit", ) SCOPE_ACTIONS = ("read", "write") # Alle gueltigen ":"-Strings, z.B. "hosts:read". VALID_SCOPES = frozenset( f"{resource}:{action}" for resource in SCOPE_RESOURCES for action in SCOPE_ACTIONS ) def generate_token() -> str: return TOKEN_PREFIX + secrets.token_urlsafe(32) def hash_token(token: str) -> str: return hashlib.sha256(token.encode() + _TOKEN_PEPPER).hexdigest() def token_prefix_for_display(token: str) -> str: """Kurzer, nicht-geheimer Praefix zur Wiedererkennung in der UI (kein Rueckschluss auf das volle Token moeglich, siehe Recovery-Code-Analogie).""" return token[: len(TOKEN_PREFIX) + 8] def validate_scopes(scopes: list[str]) -> list[str]: """Wirft ValueError bei unbekannten Scopes, sonst normalisierte, deduplizierte Liste.""" unknown = sorted(set(scopes) - VALID_SCOPES) if unknown: raise ValueError(f"Unbekannte Scope(s): {', '.join(unknown)}") return sorted(set(scopes)) def token_has_scope(granted_scopes: list[str], resource: str, action: str) -> bool: """'write' schliesst 'read' auf derselben Ressource automatisch mit ein.""" if f"{resource}:{action}" in granted_scopes: return True if action == "read" and f"{resource}:write" in granted_scopes: return True return False