Files
wiki-tcg/backend/routers/auth.py
T
2026-07-29 16:30:14 +02:00

246 lines
10 KiB
Python

import logging
import re
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from core.auth import (
DUMMY_PASSWORD_HASH, create_access_token, create_refresh_token,
decode_refresh_token_payload, hash_password, token_issued_before, verify_password,
)
from core.database import get_db
from core.dependencies import get_current_user, limiter
from services.email_utils import send_password_reset_email, send_verification_email
from core.models import User as UserModel
logger = logging.getLogger("app")
router = APIRouter()
try:
from disposable_email_domains import blocklist as _disposable_blocklist
except ImportError:
_disposable_blocklist: set[str] = set()
class RegisterRequest(BaseModel):
username: str
email: str
password: str
class ForgotPasswordRequest(BaseModel):
email: str
class ResetPasswordWithTokenRequest(BaseModel):
token: str
new_password: str
class ResetPasswordRequest(BaseModel):
current_password: str
new_password: str
class ResendVerificationRequest(BaseModel):
email: str
class RefreshRequest(BaseModel):
refresh_token: str
def validate_register(username: str, email: str, password: str) -> str | None:
if not username.strip():
return "Username is required"
if len(username) < 2:
return "Username must be at least 2 characters"
if len(username) > 16:
return "Username must be 16 characters or fewer"
# Permissive by design — unicode names are fine. Safety comes from escaping at the
# point of use: html.escape in email_utils, and exact comparison instead of ILIKE.
if len(email) > 254 or not re.match(r"^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$", email):
return "Please enter a valid email"
domain = email.split("@")[-1].lower()
if domain in _disposable_blocklist:
return "Disposable email addresses are not allowed"
if len(password) < 8:
return "Password must be at least 8 characters"
if len(password) > 256:
return "Password must be 256 characters or fewer"
return None
@router.post("/register")
@limiter.limit("5/minute")
def register(request: Request, req: RegisterRequest, db: Session = Depends(get_db)):
err = validate_register(req.username, req.email, req.password)
if err:
raise HTTPException(status_code=400, detail=err)
# Equality, not ilike: as a LIKE *pattern* an unescaped '%' matches every row and
# 'a_c' matches 'abc', so registration could be made to reject valid names.
if db.query(UserModel).filter(func.lower(UserModel.username) == req.username.lower()).first():
raise HTTPException(status_code=400, detail="Username already taken")
if db.query(UserModel).filter(UserModel.email == req.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
verification_token = secrets.token_urlsafe(32)
user = UserModel(
id=uuid.uuid4(),
username=req.username,
email=req.email,
password_hash=hash_password(req.password),
email_verified=False,
email_verification_token=verification_token,
email_verification_token_expires_at=datetime.now() + timedelta(hours=24),
)
db.add(user)
db.commit()
try:
send_verification_email(req.email, req.username, verification_token)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
raise HTTPException(
status_code=500,
detail="Account created but we couldn't send the verification email. Please use 'Resend verification' to try again."
)
return {"message": "Account created. Please check your email to verify your account."}
@router.post("/login")
@limiter.limit("10/minute")
def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(UserModel).filter(func.lower(UserModel.username) == form.username.lower()).first()
# Verify unconditionally: short-circuiting on a missing user would return before
# bcrypt runs, and that timing gap enumerates usernames just as well as an explicit
# "no such user" would — which the other handlers here deliberately avoid.
password_ok = verify_password(form.password, user.password_hash if user else DUMMY_PASSWORD_HASH)
if not user or not password_ok:
raise HTTPException(status_code=400, detail="Invalid username or password")
user.last_active_at = datetime.now()
db.commit()
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}
@router.post("/auth/reset-password")
@limiter.limit("5/minute")
def reset_password(request: Request, req: ResetPasswordRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not verify_password(req.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
if req.current_password == req.new_password:
raise HTTPException(status_code=400, detail="New password must be different from current password")
user.password_hash = hash_password(req.new_password)
user.token_valid_after = datetime.now(timezone.utc)
db.commit()
return {"message": "Password updated"}
@router.post("/auth/forgot-password")
@limiter.limit("5/minute")
def forgot_password(request: Request, req: ForgotPasswordRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success even if email not found. Prevents user enumeration
if user:
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires_at = datetime.now() + timedelta(hours=1)
db.commit()
try:
send_password_reset_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to send reset email: {e}")
raise HTTPException(
status_code=500,
detail="Failed to send the password reset email. Please try again later."
)
return {"message": "If that email is registered you will receive a reset link shortly"}
@router.post("/auth/reset-password-with-token")
@limiter.limit("5/minute")
def reset_password_with_token(request: Request, req: ResetPasswordWithTokenRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.reset_token == req.token).first()
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired reset link")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
user.password_hash = hash_password(req.new_password)
# This is the flow a locked-out or compromised user reaches for, so it must evict
# whoever else is holding a token for this account.
user.token_valid_after = datetime.now(timezone.utc)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return {"message": "Password updated"}
@router.get("/auth/verify-email")
@limiter.limit("10/minute")
def verify_email(request: Request, token: str, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email_verification_token == token).first()
if not user or not user.email_verification_token_expires_at or user.email_verification_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired verification link")
user.email_verified = True
user.email_verification_token = None
user.email_verification_token_expires_at = None
db.commit()
return {"message": "Email verified"}
@router.post("/auth/resend-verification")
@limiter.limit("5/minute")
def resend_verification(request: Request, req: ResendVerificationRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success to prevent user enumeration
if user and not user.email_verified:
token = secrets.token_urlsafe(32)
user.email_verification_token = token
user.email_verification_token_expires_at = datetime.now() + timedelta(hours=24)
db.commit()
try:
send_verification_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to resend verification email: {e}")
raise HTTPException(
status_code=500,
detail="Failed to send the verification email. Please try again later."
)
return {"message": "If that email is registered and unverified, you will receive a new verification link shortly"}
@router.post("/auth/refresh")
@limiter.limit("20/minute")
def refresh(request: Request, req: RefreshRequest, db: Session = Depends(get_db)):
payload = decode_refresh_token_payload(req.refresh_token)
if not payload or not payload.get("sub"):
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
try:
user_uuid = uuid.UUID(payload["sub"])
except ValueError:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = db.query(UserModel).filter(UserModel.id == user_uuid).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
# Refresh tokens live 30 days, so this is what actually stops a stolen one from
# being replayed into a fresh token after the owner resets their password.
if token_issued_before(payload, user.token_valid_after):
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user.last_active_at = datetime.now()
db.commit()
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}