- 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.
447 lines
18 KiB
Python
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}")
|