botboss-backend / services /security.py
muhammadtabishkha's picture
Auth/Privacy (#7): JWT issuance+verification, PBKDF2 password hashing (backward compatible), protect applications/report/CV endpoints, GDPR delete-my-data endpoint
3335ef6
Raw
History Blame Contribute Delete
3.66 kB
"""
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)