pdf-quiz-generator/backend/app/routers/auth.py
Daniel b39e09f393 Persist reminders_disabled in Postgres and harden reminder scheduler
- Add reminders_disabled boolean column to users (DB source of truth).
- Scheduler reads the DB column directly; Redis no longer used for
  opt-out checks.
- Scheduler now deactivates reminders when a user has no completed
  attempts left for a quiz (e.g., after deleting their attempts).
- Settings API: GET returns DB value for reminders_disabled; PUT
  persists that key to DB and keeps the rest of the blob in Redis.

Rollback point for Alembic wiring.
2026-04-14 04:31:03 +02:00

447 lines
18 KiB
Python

import secrets
from datetime import datetime, timedelta
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_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")
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})
return {"access_token": access_token, "token_type": "bearer"}
# Regular user — must verify email before logging in
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
@router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
# Block password login if SSO-only mode
sso_settings = _get_sso_settings()
if sso_settings["sso_only"]:
raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.")
# 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")
user.hashed_password = get_password_hash(data.new_password)
record.used = True
db.commit()
return {"message": "Password reset successfully. You can now log in."}
@router.get("/me/settings")
def get_user_settings(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get user settings. Most settings live in Redis (Nextcloud config etc.);
reminders_disabled is canonical in Postgres and overrides Redis."""
data = {}
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)
raw = r.get(f"user_settings:{current_user.id}")
if raw:
data = json.loads(raw)
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
# Canonical opt-out preference comes from the DB
data["reminders_disabled"] = bool(current_user.reminders_disabled)
return data
@router.put("/me/settings")
def save_user_settings(
settings_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Save user settings. reminders_disabled is persisted to Postgres;
other keys go to Redis."""
# Persist opt-out preference to DB (canonical source for the scheduler)
if "reminders_disabled" in settings_data:
current_user.reminders_disabled = bool(settings_data.get("reminders_disabled"))
db.add(current_user)
db.commit()
# Keep the full blob in Redis so other fields (Nextcloud config etc.) persist
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 to Redis: {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)):
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")
current_user.hashed_password = get_password_hash(data.new_password)
if data.name:
current_user.name = data.name
db.commit()
db.refresh(current_user)
return {
"id": current_user.id, "email": current_user.email,
"name": current_user.name, "role": current_user.role,
}
# ── SSO / OIDC ─────────────────────────────────────────────────────
def _get_sso_settings():
"""Return SSO admin settings from Redis."""
try:
import redis as redis_lib
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
return {
"sso_only": r.get("settings:sso_only") == "true",
}
except Exception:
return {"sso_only": False}
@router.get("/sso/config")
def sso_config():
"""Public endpoint — tells frontend whether SSO is available and login mode."""
from app.config import settings as cfg
sso_enabled = bool(cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID)
sso_settings = _get_sso_settings()
return {
"sso_enabled": sso_enabled,
"sso_only": sso_settings["sso_only"],
"provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None,
}
@router.get("/sso/login")
def sso_login(request: Request):
"""Redirect user to the OIDC provider for login."""
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from app.config import settings as cfg
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
raise HTTPException(status_code=400, detail="SSO is not configured")
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
client_id=cfg.OIDC_CLIENT_ID,
client_secret=cfg.OIDC_CLIENT_SECRET,
client_kwargs={"scope": cfg.OIDC_SCOPES},
)
redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback"
return oauth.oidc.authorize_redirect(request, redirect_uri)
@router.get("/sso/callback")
async def sso_callback(request: Request, db: Session = Depends(get_db)):
"""Handle OIDC provider callback — create or login user."""
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from app.config import settings as cfg
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
raise HTTPException(status_code=400, detail="SSO is not configured")
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
client_id=cfg.OIDC_CLIENT_ID,
client_secret=cfg.OIDC_CLIENT_SECRET,
client_kwargs={"scope": cfg.OIDC_SCOPES},
)
try:
token = await oauth.oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed")
userinfo = token.get("userinfo") or {}
email = userinfo.get("email", "").lower().strip()
name = userinfo.get("name") or userinfo.get("preferred_username") or email.split("@")[0]
if not email:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=no_email")
# Find or create user
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(
email=email,
hashed_password=get_password_hash(secrets.token_urlsafe(32)), # random password — SSO users don't use it
name=name,
role="user",
)
db.add(user)
db.flush()
# Auto-verify SSO users
verification = EmailVerification(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + timedelta(hours=1),
verified_at=datetime.utcnow(),
)
db.add(verification)
db.commit()
db.refresh(user)
access_token = create_access_token(data={"sub": user.email})
return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?token={access_token}")