- 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>
369 lines
15 KiB
Python
369 lines
15 KiB
Python
import hashlib
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models.user import User
|
|
from app.models.email_verification import EmailVerification
|
|
from app.models.password_reset import PasswordReset
|
|
from app.schemas.auth import (
|
|
UserCreate, UserResponse, Token, LoginRequest,
|
|
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest,
|
|
)
|
|
from app.services import email_service
|
|
from app.utils.auth import (
|
|
get_password_hash, verify_password, create_access_token, get_current_user,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _check_pwned_password(password: str) -> int | None:
|
|
"""Check if password appears in Have I Been Pwned breach database.
|
|
Uses k-anonymity: only first 5 chars of SHA-1 hash are sent.
|
|
Returns breach count, 0 if clean, or None if check failed."""
|
|
try:
|
|
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
|
|
prefix, suffix = sha1[:5], sha1[5:]
|
|
resp = httpx.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3)
|
|
if resp.status_code != 200:
|
|
return None
|
|
for line in resp.text.splitlines():
|
|
hash_suffix, count = line.split(":")
|
|
if hash_suffix == suffix:
|
|
return int(count)
|
|
return 0
|
|
except Exception:
|
|
return None # fail open — don't block registration if API is down
|
|
|
|
|
|
def _check_login_rate_limit(client_ip: str):
|
|
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
|
try:
|
|
import redis as redis_lib
|
|
from app.config import settings
|
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
|
key = f"login_attempts:{client_ip}"
|
|
count = r.incr(key)
|
|
if count == 1:
|
|
r.expire(key, 15 * 60)
|
|
if count > 10:
|
|
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import logging; logging.getLogger(__name__).warning(f"Redis rate limit unavailable (failing open): {e}")
|
|
|
|
|
|
# Rate limit: max 3 reset requests per email per hour
|
|
RESET_LIMIT = 3
|
|
RESET_WINDOW_HOURS = 1
|
|
|
|
|
|
def _check_reset_rate_limit(db: Session, email: str):
|
|
email_normalized = email.lower().strip()
|
|
user = db.query(User).filter(User.email == email_normalized).first()
|
|
if not user:
|
|
return # silently ignore unknown emails
|
|
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
|
|
count = db.query(PasswordReset).filter(
|
|
PasswordReset.user_id == user.id,
|
|
PasswordReset.created_at >= window_start,
|
|
).count()
|
|
if count >= RESET_LIMIT:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail=f"Too many reset requests. Please wait before trying again.",
|
|
)
|
|
|
|
|
|
async def _verify_turnstile(token: str) -> bool:
|
|
"""Verify Cloudflare Turnstile token. Returns True if valid or if not configured."""
|
|
from app.config import settings as cfg
|
|
secret = cfg.TURNSTILE_SECRET_KEY
|
|
if not secret:
|
|
return True
|
|
try:
|
|
import httpx
|
|
resp = httpx.post(
|
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
data={"secret": secret, "response": token},
|
|
timeout=10,
|
|
)
|
|
return resp.json().get("success", False)
|
|
except Exception as e:
|
|
import logging; logging.getLogger(__name__).warning(f"Turnstile verification failed (failing open): {e}")
|
|
return True
|
|
|
|
|
|
@router.post("/register")
|
|
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
|
# Verify Turnstile if configured
|
|
from app.config import settings as cfg
|
|
if cfg.TURNSTILE_SECRET_KEY:
|
|
if not user_data.turnstile_token:
|
|
raise HTTPException(status_code=400, detail="Bot verification required")
|
|
if not await _verify_turnstile(user_data.turnstile_token):
|
|
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
|
|
|
|
# Check if registration is enabled (unless this is the first user - always allow admin creation)
|
|
is_first_user = db.query(User).count() == 0
|
|
if not is_first_user:
|
|
try:
|
|
import redis as redis_lib
|
|
from app.config import settings
|
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
|
registration_enabled = r.get("settings:registration_enabled")
|
|
# Default to enabled if not set, disabled if explicitly set to "false"
|
|
if registration_enabled == "false":
|
|
raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator")
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
|
|
|
|
if len(user_data.password) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
|
|
# Check breached passwords (warn, don't block)
|
|
pwned_count = _check_pwned_password(user_data.password)
|
|
password_warning = None
|
|
if pwned_count and pwned_count > 0:
|
|
password_warning = (
|
|
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
|
"Consider changing it to something unique."
|
|
)
|
|
|
|
email_normalized = user_data.email.lower().strip()
|
|
existing = db.query(User).filter(User.email == email_normalized).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Email already registered")
|
|
user = User(
|
|
email=email_normalized,
|
|
hashed_password=get_password_hash(user_data.password),
|
|
name=user_data.name,
|
|
role="admin" if is_first_user else "user",
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
# Create email verification record
|
|
token = secrets.token_urlsafe(32)
|
|
verification = EmailVerification(
|
|
user_id=user.id,
|
|
token=token,
|
|
expires_at=datetime.utcnow() + timedelta(hours=24),
|
|
verified_at=datetime.utcnow() if is_first_user else None,
|
|
)
|
|
db.add(verification)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
if is_first_user:
|
|
# Auto-verified admin — log them in immediately
|
|
access_token = create_access_token(data={"sub": user.email})
|
|
resp = {"access_token": access_token, "token_type": "bearer"}
|
|
if password_warning:
|
|
resp["password_warning"] = password_warning
|
|
return resp
|
|
|
|
# Regular user — must verify email before logging in
|
|
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
|
resp = {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
|
|
if password_warning:
|
|
resp["password_warning"] = password_warning
|
|
return resp
|
|
|
|
|
|
@router.post("/login", response_model=Token)
|
|
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
|
|
# Verify Turnstile if configured
|
|
from app.config import settings as cfg
|
|
if cfg.TURNSTILE_SECRET_KEY:
|
|
if not login_data.turnstile_token:
|
|
raise HTTPException(status_code=400, detail="Bot verification required")
|
|
if not await _verify_turnstile(login_data.turnstile_token):
|
|
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
|
|
|
|
if request:
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
_check_login_rate_limit(client_ip)
|
|
|
|
email_normalized = login_data.email.lower().strip()
|
|
user = db.query(User).filter(User.email == email_normalized).first()
|
|
if not user or not verify_password(login_data.password, user.hashed_password):
|
|
raise HTTPException(status_code=401, detail="Invalid email or password")
|
|
|
|
# Check email verification — skip for users without any verification record (legacy/seeded)
|
|
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 check your inbox and verify your email before logging in.",
|
|
)
|
|
|
|
access_token = create_access_token(data={"sub": user.email})
|
|
return Token(access_token=access_token)
|
|
|
|
|
|
@router.get("/verify-email")
|
|
def verify_email(token: str, db: Session = Depends(get_db)):
|
|
record = db.query(EmailVerification).filter(EmailVerification.token == token).first()
|
|
if not record:
|
|
raise HTTPException(status_code=400, detail="Invalid verification token")
|
|
if record.verified_at is not None:
|
|
return {"message": "Email already verified. You can log in."}
|
|
if datetime.utcnow() > record.expires_at:
|
|
raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.")
|
|
|
|
record.verified_at = datetime.utcnow()
|
|
db.commit()
|
|
return {"message": "Email verified successfully! You can now log in."}
|
|
|
|
|
|
@router.post("/resend-verification")
|
|
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
|
email_normalized = data.email.lower().strip()
|
|
user = db.query(User).filter(User.email == email_normalized).first()
|
|
if not user:
|
|
return {"message": "If that email exists, a verification link has been sent."}
|
|
|
|
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
|
if record and record.verified_at is not None:
|
|
return {"message": "Email already verified."}
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
if record:
|
|
record.token = token
|
|
record.expires_at = datetime.utcnow() + timedelta(hours=24)
|
|
else:
|
|
db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24)))
|
|
db.commit()
|
|
|
|
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
|
return {"message": "If that email exists, a verification link has been sent."}
|
|
|
|
|
|
@router.post("/forgot-password")
|
|
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
|
email_normalized = data.email.lower().strip()
|
|
_check_reset_rate_limit(db, email_normalized)
|
|
|
|
user = db.query(User).filter(User.email == email_normalized).first()
|
|
# Always return same message to avoid email enumeration
|
|
if not user:
|
|
return {"message": "If that email is registered, a reset link has been sent."}
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
reset = PasswordReset(
|
|
user_id=user.id,
|
|
token=token,
|
|
expires_at=datetime.utcnow() + timedelta(hours=1),
|
|
)
|
|
db.add(reset)
|
|
db.commit()
|
|
|
|
background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token)
|
|
return {"message": "If that email is registered, a reset link has been sent."}
|
|
|
|
|
|
@router.post("/reset-password")
|
|
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
|
|
if len(data.new_password) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
|
|
record = db.query(PasswordReset).filter(
|
|
PasswordReset.token == data.token,
|
|
PasswordReset.used == False,
|
|
).first()
|
|
if not record:
|
|
raise HTTPException(status_code=400, detail="Invalid or already used reset token")
|
|
if datetime.utcnow() > record.expires_at:
|
|
raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.")
|
|
|
|
user = db.query(User).filter(User.id == record.user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
pwned_count = _check_pwned_password(data.new_password)
|
|
user.hashed_password = get_password_hash(data.new_password)
|
|
record.used = True
|
|
db.commit()
|
|
resp = {"message": "Password reset successfully. You can now log in."}
|
|
if pwned_count and pwned_count > 0:
|
|
resp["password_warning"] = (
|
|
f"Warning: this password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
|
"Consider changing it to something more unique."
|
|
)
|
|
return resp
|
|
|
|
|
|
@router.get("/me/settings")
|
|
def get_user_settings(current_user: User = Depends(get_current_user)):
|
|
"""Get user settings stored in Redis (Nextcloud config etc.)."""
|
|
try:
|
|
import redis as redis_lib, json
|
|
from app.config import settings as cfg
|
|
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
|
|
data = r.get(f"user_settings:{current_user.id}")
|
|
return json.loads(data) if data else {}
|
|
except Exception as e:
|
|
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
|
|
return {}
|
|
|
|
|
|
@router.put("/me/settings")
|
|
def save_user_settings(
|
|
settings_data: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Save user settings to Redis."""
|
|
try:
|
|
import redis as redis_lib, json
|
|
from app.config import settings as cfg
|
|
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
|
|
r.set(f"user_settings:{current_user.id}", json.dumps(settings_data))
|
|
except Exception as e:
|
|
import logging; logging.getLogger(__name__).warning(f"Failed to save user settings: {e}")
|
|
return {"saved": True}
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
def get_me(current_user: User = Depends(get_current_user)):
|
|
return current_user
|
|
|
|
|
|
@router.put("/me")
|
|
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
password_warning = None
|
|
if data.new_password:
|
|
if not data.current_password:
|
|
raise HTTPException(status_code=400, detail="Current password required to set a new one")
|
|
if not verify_password(data.current_password, current_user.hashed_password):
|
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
|
if len(data.new_password) < 8:
|
|
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
|
pwned_count = _check_pwned_password(data.new_password)
|
|
if pwned_count and pwned_count > 0:
|
|
password_warning = (
|
|
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
|
"Consider changing it to something more unique."
|
|
)
|
|
current_user.hashed_password = get_password_hash(data.new_password)
|
|
|
|
if data.name:
|
|
current_user.name = data.name
|
|
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
resp = {
|
|
"id": current_user.id, "email": current_user.email,
|
|
"name": current_user.name, "role": current_user.role,
|
|
}
|
|
if password_warning:
|
|
resp["password_warning"] = password_warning
|
|
return resp
|