66 lines
2.1 KiB
Python
66 lines
2.1 KiB
Python
"""
|
|
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: "<ressource>:<aktion>" 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",
|
|
"roles",
|
|
"user_groups",
|
|
"audit",
|
|
)
|
|
SCOPE_ACTIONS = ("read", "write")
|
|
|
|
# Alle gueltigen "<ressource>:<aktion>"-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
|