from datetime import datetime, timedelta import bcrypt from fastapi import Depends, HTTPException, status, Request, Response from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy.orm import Session from starlette.middleware.base import BaseHTTPMiddleware from app.config import settings from app.database import get_db from app.models.user import User oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") def verify_password(plain_password: str, hashed_password: str) -> bool: return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) def get_password_hash(password: str) -> str: return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: to_encode = data.copy() expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire, "iat": datetime.utcnow().timestamp()}) return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) def decode_token(token: str): """Decode JWT token and return payload, or None if invalid.""" try: return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) except JWTError: return None def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) email: str = payload.get("sub") if email is None: raise credentials_exception except JWTError: raise credentials_exception user = db.query(User).filter(User.email == email.lower().strip()).first() if user is None: raise credentials_exception # Block unverified users from all protected endpoints from app.models.email_verification import EmailVerification verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() if verification and verification.verified_at is None: raise HTTPException( status_code=403, detail="Email not verified. Please verify your email before using the app.", headers={"X-Unverified": "true"}, ) return user def require_admin(current_user: User = Depends(get_current_user)) -> User: if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin access required") return current_user def require_moderator(current_user: User = Depends(get_current_user)) -> User: if not current_user.is_moderator: raise HTTPException(status_code=403, detail="Moderator access required") return current_user class TokenRefreshMiddleware(BaseHTTPMiddleware): """Middleware to refresh JWT token if it's older than 12 hours or expires soon (sliding expiration).""" async def dispatch(self, request: Request, call_next): # Extract token from Authorization header auth_header = request.headers.get("authorization", "") new_token = None if auth_header.startswith("Bearer "): token = auth_header[7:] payload = decode_token(token) if payload: iat = payload.get("iat") exp = payload.get("exp") now = datetime.utcnow().timestamp() if iat and exp: age_hours = (now - iat) / 3600 time_until_expiry_hours = (exp - now) / 3600 # Refresh if: token > 12 hours old OR will expire in < 1 hour if age_hours > 12 or time_until_expiry_hours < 1: email = payload.get("sub") if email: new_token = create_access_token({"sub": email}) response = await call_next(request) # Add new token to response headers if generated if new_token: response.headers["X-New-Token"] = new_token return response def check_rate_limit(key: str, max_calls: int, window_seconds: int, detail: str, user=None): """Generic Redis-backed rate limiter. Admins and unthrottled users are always exempt. Degrades gracefully if Redis is down.""" # Admins, moderators, and explicitly unthrottled users bypass all rate limits if user is not None: if getattr(user, 'role', '') in ('admin', 'moderator') or getattr(user, 'is_unthrottled', 0): return try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1) count = r.incr(key) if count == 1: r.expire(key, window_seconds) if count > max_calls: raise HTTPException(status_code=429, detail=detail) except HTTPException: raise except Exception: pass # Redis unavailable — fail open rather than blocking users