""" Authentication & security helpers — JWT issuance/verification + password hashing. - Passwords are hashed with PBKDF2-HMAC-SHA256 (Python stdlib, no native build deps). Legacy plaintext passwords still verify (and are upgraded to a hash on next login), so existing accounts keep working. - JWTs are signed with HS256. Set a strong JWT_SECRET as an HF Space secret in prod. """ import os import time import base64 import hmac import hashlib import secrets import logging import jwt # PyJWT from fastapi import Depends, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials logger = logging.getLogger(__name__) JWT_SECRET = os.getenv("JWT_SECRET", "botboss-dev-secret-change-me") JWT_ALGO = "HS256" JWT_TTL_HOURS = int(os.getenv("JWT_TTL_HOURS", "168")) # 7 days # ── Password hashing (PBKDF2, stdlib) ──────────────────────────────────────── _PBKDF2_ROUNDS = 200_000 def hash_password(password: str) -> str: salt = secrets.token_bytes(16) dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode(), salt, _PBKDF2_ROUNDS) return "pbkdf2$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(dk).decode() def verify_password(password: str, stored: str) -> bool: if not stored: return False if stored.startswith("pbkdf2$"): try: _, salt_b64, dk_b64 = stored.split("$") salt = base64.b64decode(salt_b64) expected = base64.b64decode(dk_b64) dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode(), salt, _PBKDF2_ROUNDS) return hmac.compare_digest(dk, expected) except Exception: return False # Legacy plaintext account (pre-hashing) — compare directly. return hmac.compare_digest(password or "", stored) def is_hashed(stored: str) -> bool: return bool(stored) and stored.startswith("pbkdf2$") # ── JWT ────────────────────────────────────────────────────────────────────── def create_token(user: dict) -> str: now = int(time.time()) payload = { "sub": user.get("id"), "email": user.get("email"), "type": user.get("type"), "name": user.get("name"), "iat": now, "exp": now + JWT_TTL_HOURS * 3600, } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO) def decode_token(token: str): try: return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO]) except Exception: return None # ── FastAPI dependencies ───────────────────────────────────────────────────── _bearer = HTTPBearer(auto_error=False) def get_current_user(creds: HTTPAuthorizationCredentials = Depends(_bearer)): """Require a valid JWT. Returns the token payload {sub,email,type,name}.""" if not creds or not creds.credentials: raise HTTPException(status_code=401, detail="Authentication required") payload = decode_token(creds.credentials) if not payload: raise HTTPException(status_code=401, detail="Invalid or expired token") return payload def get_optional_user(creds: HTTPAuthorizationCredentials = Depends(_bearer)): """Return the token payload if present/valid, else None (for adaptive endpoints).""" if not creds or not creds.credentials: return None return decode_token(creds.credentials)