- 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>
139 lines
5.1 KiB
Python
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
|