"""Auth router — registration, login, refresh, logout.""" from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Response, Request, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select import httpx from app.database import get_db from app.config import get_settings from app.models import User from app.schemas import UserRegister, UserLogin, TokenResponse, UserMe from app.middleware.auth import ( hash_password, verify_password, create_access_token, create_refresh_token, decode_token, blocklist_token, is_token_blocklisted, get_current_user, ) router = APIRouter() settings = get_settings() REFRESH_COOKIE_NAME = "fractafrag_refresh" REFRESH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days async def verify_turnstile(token: str) -> bool: """Verify Cloudflare Turnstile token server-side.""" if not settings.turnstile_secret: return True # Skip in dev if not configured async with httpx.AsyncClient() as client: resp = await client.post( "https://challenges.cloudflare.com/turnstile/v0/siteverify", data={"secret": settings.turnstile_secret, "response": token}, ) result = resp.json() return result.get("success", False) def set_refresh_cookie(response: Response, token: str): response.set_cookie( key=REFRESH_COOKIE_NAME, value=token, max_age=REFRESH_COOKIE_MAX_AGE, httponly=True, secure=True, samesite="lax", path="/api/v1/auth", ) @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def register( body: UserRegister, response: Response, db: AsyncSession = Depends(get_db), ): # Verify Turnstile if not await verify_turnstile(body.turnstile_token): raise HTTPException(status_code=400, detail="CAPTCHA verification failed") # Check for existing user existing = await db.execute( select(User).where((User.email == body.email) | (User.username == body.username)) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Username or email already taken") # Create user user = User( username=body.username, email=body.email, password_hash=hash_password(body.password), ) db.add(user) await db.flush() # Issue tokens access = create_access_token(user.id, user.username, user.role, user.subscription_tier) refresh = create_refresh_token(user.id) set_refresh_cookie(response, refresh) return TokenResponse(access_token=access) @router.post("/login", response_model=TokenResponse) async def login( body: UserLogin, response: Response, db: AsyncSession = Depends(get_db), ): if not await verify_turnstile(body.turnstile_token): raise HTTPException(status_code=400, detail="CAPTCHA verification failed") result = await db.execute(select(User).where(User.email == body.email)) user = result.scalar_one_or_none() if not user or not verify_password(body.password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid email or password") # Update last active user.last_active_at = datetime.now(timezone.utc) access = create_access_token(user.id, user.username, user.role, user.subscription_tier) refresh = create_refresh_token(user.id) set_refresh_cookie(response, refresh) return TokenResponse(access_token=access) @router.post("/refresh", response_model=TokenResponse) async def refresh_token( request: Request, response: Response, db: AsyncSession = Depends(get_db), ): token = request.cookies.get(REFRESH_COOKIE_NAME) if not token: raise HTTPException(status_code=401, detail="No refresh token") if await is_token_blocklisted(token): raise HTTPException(status_code=401, detail="Token has been revoked") payload = decode_token(token) if payload.get("type") != "refresh": raise HTTPException(status_code=401, detail="Not a refresh token") user_id = payload.get("sub") result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="User not found") # Rotate: blocklist old refresh, issue new pair ttl = settings.jwt_refresh_token_expire_days * 86400 await blocklist_token(token, ttl) access = create_access_token(user.id, user.username, user.role, user.subscription_tier) new_refresh = create_refresh_token(user.id) set_refresh_cookie(response, new_refresh) return TokenResponse(access_token=access) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout( request: Request, response: Response, user: User = Depends(get_current_user), ): token = request.cookies.get(REFRESH_COOKIE_NAME) if token: ttl = settings.jwt_refresh_token_expire_days * 86400 await blocklist_token(token, ttl) response.delete_cookie(REFRESH_COOKIE_NAME, path="/api/v1/auth")