"""Password hashing, API/session tokens, and FastAPI auth dependencies. Stdlib-only crypto: PBKDF2-SHA256 for passwords, SHA-256 hashed bearer tokens for both the desktop client (kind="api") and the WebUI session (kind="session"). """ import hashlib import secrets from datetime import datetime, timezone from fastapi import Depends, Header, HTTPException, Request, status from app.db import get_connection _PBKDF2_ITERATIONS = 600_000 ADMIN_ROLE = "admin" EDITOR_ROLE = "editor" USER_ROLE = "user" ROLES = (ADMIN_ROLE, EDITOR_ROLE, USER_ROLE) def now_iso(): return datetime.now(timezone.utc).isoformat(timespec="seconds") def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS) return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" def verify_password(password: str, stored: str) -> bool: try: scheme, iterations, salt_hex, digest_hex = stored.split("$") if scheme != "pbkdf2_sha256": return False salt = bytes.fromhex(salt_hex) expected = bytes.fromhex(digest_hex) actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations)) return secrets.compare_digest(actual, expected) except (ValueError, TypeError): return False def _hash_token(raw: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() def issue_token(user_id: int, kind: str = "api", label: str = "", expires_at: str = "") -> str: """Create a token row and return the raw token (only shown once).""" raw = secrets.token_urlsafe(32) conn = get_connection() try: conn.execute( "INSERT INTO api_tokens (user_id, token_hash, label, kind, created_at, last_used_at, expires_at, revoked_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, NULL)", (user_id, _hash_token(raw), label, kind, now_iso(), now_iso(), expires_at or None), ) conn.commit() finally: conn.close() return raw def revoke_token(raw: str) -> None: conn = get_connection() try: conn.execute( "UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ?", (now_iso(), _hash_token(raw)), ) conn.commit() finally: conn.close() def get_user_by_token(raw: str): if not raw: return None conn = get_connection() try: row = conn.execute( "SELECT u.id, u.username, u.role, u.is_active FROM api_tokens t " "JOIN users u ON u.id = t.user_id " "WHERE t.token_hash = ? AND t.revoked_at IS NULL " "AND (t.expires_at IS NULL OR t.expires_at = '' OR t.expires_at > ?)", (_hash_token(raw), now_iso()), ).fetchone() if row is None or not row["is_active"]: return None conn.execute( "UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?", (now_iso(), _hash_token(raw)), ) conn.commit() return dict(row) finally: conn.close() def get_current_user(request: Request, authorization: str = Header(default="")): """Authenticate via ``Authorization: Bearer`` (client) or session cookie (WebUI).""" token = "" if authorization.startswith("Bearer "): token = authorization[7:].strip() else: token = request.cookies.get("psppt_session", "") user = get_user_by_token(token) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") return user def require_role(*roles): """Dependency factory: only users with one of ``roles`` may pass.""" def dependency(user=Depends(get_current_user)): if user["role"] not in roles: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") return user return dependency def ensure_admin(username: str, password: str) -> bool: """Create the initial admin account when the users table is empty.""" conn = get_connection() try: count = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] if count == 0: conn.execute( "INSERT INTO users (username, password_hash, role, is_active, created_at) " "VALUES (?, ?, 'admin', 1, ?)", (username, hash_password(password), now_iso()), ) conn.commit() return True finally: conn.close() return False