- Extended SetupRequest model to include all configuration fields - Updated setup API endpoint to handle comprehensive configuration - Created new setup.html with organized configuration sections - Enhanced config modal in index.html with all settings - Updated JavaScript modules to use unified config API - Added backup configuration section - Documented new features in features.md and instructions.md
242 lines
8.5 KiB
Python
242 lines
8.5 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 all provided values
|
|
and saves them to config.json.
|
|
"""
|
|
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 all provided values
|
|
config_service = get_config_service()
|
|
try:
|
|
config = config_service.load_config()
|
|
except Exception:
|
|
# If config doesn't exist, create default
|
|
from src.server.models.config import (
|
|
SchedulerConfig,
|
|
LoggingConfig,
|
|
BackupConfig,
|
|
NFOConfig,
|
|
)
|
|
config = AppConfig()
|
|
|
|
# Update basic settings
|
|
if req.name:
|
|
config.name = req.name
|
|
if req.data_dir:
|
|
config.data_dir = req.data_dir
|
|
|
|
# Update scheduler configuration
|
|
if req.scheduler_enabled is not None:
|
|
config.scheduler.enabled = req.scheduler_enabled
|
|
if req.scheduler_interval_minutes is not None:
|
|
config.scheduler.interval_minutes = req.scheduler_interval_minutes
|
|
|
|
# Update logging configuration
|
|
if req.logging_level:
|
|
config.logging.level = req.logging_level.upper()
|
|
if req.logging_file is not None:
|
|
config.logging.file = req.logging_file
|
|
if req.logging_max_bytes is not None:
|
|
config.logging.max_bytes = req.logging_max_bytes
|
|
if req.logging_backup_count is not None:
|
|
config.logging.backup_count = req.logging_backup_count
|
|
|
|
# Update backup configuration
|
|
if req.backup_enabled is not None:
|
|
config.backup.enabled = req.backup_enabled
|
|
if req.backup_path:
|
|
config.backup.path = req.backup_path
|
|
if req.backup_keep_days is not None:
|
|
config.backup.keep_days = req.backup_keep_days
|
|
|
|
# Update NFO configuration
|
|
if req.nfo_tmdb_api_key is not None:
|
|
config.nfo.tmdb_api_key = req.nfo_tmdb_api_key
|
|
if req.nfo_auto_create is not None:
|
|
config.nfo.auto_create = req.nfo_auto_create
|
|
if req.nfo_update_on_scan is not None:
|
|
config.nfo.update_on_scan = req.nfo_update_on_scan
|
|
if req.nfo_download_poster is not None:
|
|
config.nfo.download_poster = req.nfo_download_poster
|
|
if req.nfo_download_logo is not None:
|
|
config.nfo.download_logo = req.nfo_download_logo
|
|
if req.nfo_download_fanart is not None:
|
|
config.nfo.download_fanart = req.nfo_download_fanart
|
|
if req.nfo_image_size:
|
|
config.nfo.image_size = req.nfo_image_size.lower()
|
|
|
|
# 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 req.anime_directory:
|
|
anime_directory = req.anime_directory.strip()
|
|
if anime_directory:
|
|
config.other['anime_directory'] = anime_directory
|
|
|
|
# Save the config with all updates
|
|
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,
|
|
}
|
|
|