pdf-quiz-generator/backend/app/routers/auth.py
Daniel 1c66eb84b1 fix: two ways to be told an address has an account, and a door to writing
`/auth/forgot-password` and `/auth/resend-verification` both take care to say
"if that email exists" and both then answered the question anyway.

The reset limiter returned early for an unknown address, so it counted nothing
for one and counted for the other: ask four times and a registered address
gets 429 while an unknown one gets 200 for ever. It counts either way now — in
Redis for an address with no rows to count, keyed by a fingerprint, because a
list of addresses somebody tried is itself worth not keeping.

Resend answered "Email already verified." for a known verified address and "if
that email exists" for everything else, which is not a hint but an answer. One
sentence for every outcome now.

And Editorial has a way to write something. Drafting was only reachable from
the library — a page about reading, behind a button an educator arriving to
work has no reason to look for — so the two panels now open from a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:18:30 +02:00

478 lines
20 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.services import captcha, invites, site_settings
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):
"""Refuse a fourth request in an hour — for any address, not only a real one.
Returning early for an unknown address made this the oracle the careful
wording below exists to avoid: ask four times and a registered address
answers 429 while an unknown one answers 200 for ever. The limit is now
counted against the address as typed, so both answer the same.
"""
email_normalized = email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
if user is None:
# No rows to count for an address with no account, so the attempts are
# counted in Redis instead — keyed by a fingerprint, because a list of
# addresses somebody tried is itself worth not keeping.
import hashlib
from app.utils.auth import check_rate_limit
check_rate_limit(
key=f"pwreset:{hashlib.sha256(email_normalized.encode()).hexdigest()}",
max_calls=RESET_LIMIT, window_seconds=RESET_WINDOW_HOURS * 3600,
detail="Too many reset requests. Please wait before trying again.")
return
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.",
)
@router.get("/signup-policy")
def signup_policy(db: Session = Depends(get_db)):
"""What a would-be member needs, before they are anybody.
Unauthenticated on purpose: the registration form has to know whether to
ask for a code, and it is asking before it has an account to ask with. It
says only whether one is needed — never whether a given code is valid,
which would turn this into somewhere to guess them.
"""
# The very first account is always allowed, or a new install could lock
# itself out before an administrator exists to issue a code.
if db.query(User).count() == 0:
return {"invite_required": False, "first_user": True, "registration_open": True}
# Whether anyone may join at all, so the sign-in page can stop offering a
# door that is locked. It was offering one: registration can be turned off
# site-wide, and the only way to find out was to fill the form in and be
# refused.
return {"invite_required": site_settings.get_flag("invite_only"),
"first_user": False,
"registration_open": site_settings.get_flag("registration_enabled")}
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
# Failing open: an hCaptcha outage should cost the site a little spam, not
# every account that would have been created while it lasted.
await captcha.require_human(user_data.captcha_token, fail_open=True)
# 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}")
# Invite-only: a code is checked before anything is created, and spent only
# once the account exists, so a failure part-way through does not burn it.
invite = None
if not is_first_user and site_settings.get_flag("invite_only"):
invite = invites.usable(db, user_data.invite_code)
if invite is None:
raise HTTPException(403, "This site is invite-only. A valid invite code is required.")
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()
if invite is not None:
invites.spend(db, invite, user)
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.")
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:
# The same sentence as every other outcome. Saying "already verified"
# here told anybody who asked that the address has an account and that
# it is in use — which is the whole of what the wording below is for.
return {"message": "If that email exists, a verification link has been sent."}
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 preferences come from the DB. Redis holds the rest, and a cache
# is not where a choice somebody made once should live.
data["reminders_disabled"] = bool(current_user.reminders_disabled)
data["tts_voice"] = current_user.tts_voice
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)
changed = False
if "reminders_disabled" in settings_data:
current_user.reminders_disabled = bool(settings_data.get("reminders_disabled"))
changed = True
if "tts_voice" in settings_data:
voice = (settings_data.get("tts_voice") or "").strip()
current_user.tts_voice = voice or None
changed = True
if changed:
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}")