blackboxai-api / app /auth.py
teletubbies's picture
Fix HF iframe embed and production auth over proxy
d941795
Raw
History Blame Contribute Delete
6.63 kB
"""
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