""" JWT & password hashing utilities. Security model: - Tokens are validated from HttpOnly cookies ONLY (Issue 5.1 fix). - All issued tokens carry a unique JTI (JWT ID) claim. - On logout, the JTI is added to an in-memory blocklist (Issue 5.2 fix). - decode_token() rejects any token whose JTI is on the blocklist. """ import logging import asyncio from datetime import datetime, timedelta from typing import Optional from uuid import uuid4 from fastapi import Depends, HTTPException, status, Request from jose import JWTError, jwt from passlib.context import CryptContext from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.config import settings from app.database import get_db from app.models import User logger = logging.getLogger(__name__) def _is_request_https(request: Request) -> bool: forwarded_proto = request.headers.get("x-forwarded-proto", "") return request.url.scheme == "https" or "https" in forwarded_proto.lower() # ── Password helpers ───────────────────────────────────── # Using bcrypt via passlib. The bcrypt.__about__ monkey-patch below keeps # passlib 1.7.4 compatible with bcrypt 4.1+ which removed that attribute. import bcrypt as _bcrypt_mod if not hasattr(_bcrypt_mod, "__about__"): class _BcryptAbout: __version__ = _bcrypt_mod.__version__ _bcrypt_mod.__about__ = _BcryptAbout() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) # ── JWT blocklist (in-memory, fix for Issue 5.2) ───────── # Maps jti → expiry datetime. Cleaned up lazily on each check. _token_blocklist: dict[str, datetime] = {} _cleanup_task: Optional[asyncio.Task] = None def _purge_expired_blocklist() -> None: """Remove expired entries to prevent unbounded memory growth.""" now = datetime.utcnow() expired = [jti for jti, exp in _token_blocklist.items() if exp < now] for jti in expired: del _token_blocklist[jti] if expired: logger.debug("Blocklist cleanup: removed %d expired tokens, remaining: %d", len(expired), len(_token_blocklist)) async def periodic_blocklist_cleanup() -> None: """ Background task that purges expired token blocklist entries every hour. Prevents unbounded memory growth in long-running deployments. """ while True: try: await asyncio.sleep(3600) # Every hour _purge_expired_blocklist() logger.info("Periodic blocklist cleanup complete. Size: %d entries", len(_token_blocklist)) except asyncio.CancelledError: logger.info("Blocklist cleanup task cancelled") break except Exception as e: logger.error("Error in blocklist cleanup: %s", str(e), exc_info=True) def revoke_token(payload: dict) -> None: """Add a token's JTI to the blocklist. Called on logout.""" jti = payload.get("jti") exp = payload.get("exp") if jti and exp: _purge_expired_blocklist() _token_blocklist[jti] = datetime.utcfromtimestamp(exp) logger.info("Token revoked: jti=%s", jti) # ── JWT helpers ────────────────────────────────────────── def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() # JWT spec: 'sub' must be a string if "sub" in to_encode: to_encode["sub"] = str(to_encode["sub"]) expire = datetime.utcnow() + ( expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) ) # Add a unique JTI so tokens can be individually revoked to_encode.update({"exp": expire, "jti": str(uuid4())}) return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) def decode_token(token: str) -> dict: try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) except JWTError as e: logger.warning("JWT validation failed: %s — %s", type(e).__name__, str(e)[:100]) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) # Blocklist check (Issue 5.2) jti = payload.get("jti") if jti and jti in _token_blocklist: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has been revoked", ) return payload # ── FastAPI dependencies ───────────────────────────────── def get_token(request: Request) -> str: """ Extract JWT from HttpOnly cookie ONLY (Issue 5.1 fix). Bearer-header auth is intentionally removed to prevent XSS bypass. For production deployments, verify HTTPS is enforced by reverse proxy. """ # Enforce HTTPS in production if settings.ENVIRONMENT == "production" and settings.FORCE_HTTPS_COOKIES: if not _is_request_https(request): logger.warning("Security warning: Cookie sent over %s in production", request.url.scheme) raise HTTPException(status_code=403, detail="HTTPS required") cookie_token = request.cookies.get("access_token") if cookie_token: return cookie_token raise HTTPException(status_code=401, detail="Not authenticated") async def get_current_user_id_only( token: str = Depends(get_token), ) -> int: payload = decode_token(token) sub = payload.get("sub") if sub is None: raise HTTPException(status_code=401, detail="Invalid token payload") try: return int(sub) except (ValueError, TypeError): raise HTTPException(status_code=401, detail="Invalid token payload") async def get_current_user( user_id: int = Depends(get_current_user_id_only), db: AsyncSession = Depends(get_db), ) -> User: result = await db.execute(select(User).filter(User.id == user_id)) user = result.scalars().first() if user is None: raise HTTPException(status_code=401, detail="User not found") return user async def require_admin(current_user: User = Depends(get_current_user)) -> User: if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin access required") return current_user