pdf-quiz-generator/backend/app/utils/auth.py
Daniel 8137d5b20c Decouple course quizzes, replace passlib, add security hardening
- Fully decouple course quizzes from main quiz system (hidden from
  dashboard stats, history, search, results page)
- Course quiz results show "Back to Course" instead of retake/delete
- Add allow_review toggle for course creators to control answer review
- Show quiz title on course page, hide pool size from students
- Add course thumbnails to browse cards
- Replace passlib with bcrypt directly (compatible with existing hashes)
- Add HIBP breached password warnings on register/reset/change password
- Add CLI management tools (reset-password, set-role, stats, etc.)
- Fix quiz PATCH endpoint: ownership check instead of moderator-only
- Add max_length validation on course/module/lesson titles
- Fix score display bug on results page (0 of N when review disabled)
- Fix question count on course quiz start (show per-attempt, not pool)
- Improve suspend warning for timed course quizzes with max attempts
- Clean up validation error messages (show "Invalid email" not Pydantic dump)
- Add DDL migration for allow_review column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:31:05 +02:00

139 lines
5.1 KiB
Python

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