"""Authentication API endpoints for Aniworld.""" from typing import Optional from fastapi import APIRouter, Depends, HTTPException from fastapi import status as http_status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.server.models.auth import ( AuthStatus, LoginRequest, LoginResponse, RegisterRequest, SetupRequest, ) from src.server.models.config import AppConfig from src.server.services.auth_service import AuthError, LockedOutError, auth_service from src.server.services.config_service import get_config_service # NOTE: import dependencies (optional_auth, security) lazily inside handlers # to avoid importing heavyweight modules (e.g. sqlalchemy) at import time. router = APIRouter(prefix="/api/auth", tags=["auth"]) # HTTPBearer for optional authentication optional_bearer = HTTPBearer(auto_error=False) @router.post("/setup", status_code=http_status.HTTP_201_CREATED) async def setup_auth(req: SetupRequest): """Initial setup endpoint to configure the master password. This endpoint also initializes the configuration with default values and saves the anime directory and master password hash. If anime_directory is provided, runs migration for existing data files. """ if auth_service.is_configured(): raise HTTPException( status_code=http_status.HTTP_400_BAD_REQUEST, detail="Master password already configured", ) try: # Set up master password (this validates and hashes it) password_hash = auth_service.setup_master_password( req.master_password ) # Initialize or update config with master password hash # and anime directory config_service = get_config_service() try: config = config_service.load_config() except Exception: # If config doesn't exist, create default config = AppConfig() # Store master password hash in config's other field config.other['master_password_hash'] = password_hash # Store anime directory in config's other field if provided anime_directory = None if hasattr(req, 'anime_directory') and req.anime_directory: anime_directory = req.anime_directory.strip() if anime_directory: config.other['anime_directory'] = anime_directory # Save the config with the password hash and anime directory config_service.save_config(config, create_backup=False) return {"status": "ok"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @router.post("/login", response_model=LoginResponse) def login(req: LoginRequest): """Validate master password and return JWT token.""" # Use a simple identifier for failed attempts; prefer IP in real app identifier = "global" try: valid = auth_service.validate_master_password( req.password, identifier=identifier ) except LockedOutError as e: raise HTTPException( status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e), ) from e except AuthError as e: # Return 401 for authentication errors (including not configured) # This prevents information leakage about system configuration raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" ) from e if not valid: raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" ) token = auth_service.create_access_token( subject="master", remember=bool(req.remember) ) return token @router.post("/logout") def logout_endpoint( credentials: Optional[HTTPAuthorizationCredentials] = Depends( optional_bearer ), ): """Logout by revoking token (no-op for stateless JWT).""" # If a plain credentials object was provided, extract token token = getattr(credentials, "credentials", None) # Placeholder; auth_service.revoke_token can be expanded to persist # revocations if token: auth_service.revoke_token(token) return {"status": "ok", "message": "Logged out successfully"} async def get_optional_auth( credentials: Optional[HTTPAuthorizationCredentials] = Depends( optional_bearer ), ) -> Optional[dict]: """Get optional authentication from bearer token.""" if credentials is None: return None token = credentials.credentials try: # Validate and decode token using the auth service session = auth_service.create_session_model(token) return session.model_dump() except AuthError: return None @router.get("/status", response_model=AuthStatus) async def auth_status(auth: Optional[dict] = Depends(get_optional_auth)): """Return whether master password is configured and authenticated.""" return AuthStatus( configured=auth_service.is_configured(), authenticated=bool(auth) ) @router.post("/register", status_code=http_status.HTTP_201_CREATED) def register(req: RegisterRequest): """Register a new user (for testing/validation purposes). Note: This is primarily for input validation testing. The actual Aniworld app uses a single master password. """ # This endpoint is primarily for input validation testing # In a real multi-user system, you'd create the user here return { "status": "ok", "message": "User registration successful", "username": req.username, }