191 lines
6.4 KiB
Python

"""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 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)
# Sync series from data files to database if anime directory is set
if anime_directory:
try:
import structlog
from src.server.services.anime_service import (
sync_series_from_data_files,
)
logger = structlog.get_logger(__name__)
sync_count = await sync_series_from_data_files(
anime_directory, logger
)
logger.info(
"Setup complete: synced series from data files",
count=sync_count
)
except Exception as e:
# Log but don't fail setup if sync fails
import structlog
structlog.get_logger(__name__).warning(
"Failed to sync series after setup",
error=str(e)
)
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,
}