246 lines
10 KiB
Python
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",
|
|
}
|