Spaces:
Sleeping
Sleeping
Auth/Privacy (#7): JWT issuance+verification, PBKDF2 password hashing (backward compatible), protect applications/report/CV endpoints, GDPR delete-my-data endpoint
3335ef6 | """ | |
| 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) | |