168 lines
5.6 KiB
Python
168 lines
5.6 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 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,
|
|
}
|
|
|