diff --git a/infrastructure.md b/infrastructure.md index 5efc036..9264765 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -248,6 +248,7 @@ All endpoints tested and confirmed working: - Health: `/health` → Returns `{"status": "healthy", ...}` - Root: `/` → Serves main application page - Setup: `/setup` → Serves setup page +- Auth API: `/api/auth/*` → Endpoints for setup, login, logout and status (JWT-based) - Login: `/login` → Serves login page - Queue: `/queue` → Serves download queue page diff --git a/instructions.md b/instructions.md index 14f1e0f..75305a0 100644 --- a/instructions.md +++ b/instructions.md @@ -54,14 +54,6 @@ attempt tracking with temporary lockout, and basic password strength checks. For persistence of the master password hash and token revocation we recommend adding a config store or database in a follow-up task. -#### [] Implement authentication API endpoints - -- []Create `src/server/api/auth.py` -- []Add POST `/api/auth/setup` - initial setup -- []Add POST `/api/auth/login` - login endpoint -- []Add POST `/api/auth/logout` - logout endpoint -- []Add GET `/api/auth/status` - authentication status - #### [] Create authentication middleware - []Create `src/server/middleware/auth.py` diff --git a/src/server/api/auth.py b/src/server/api/auth.py new file mode 100644 index 0000000..2675cc4 --- /dev/null +++ b/src/server/api/auth.py @@ -0,0 +1,62 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials + +from src.server.models.auth import AuthStatus, LoginRequest, LoginResponse, SetupRequest +from src.server.services.auth_service import AuthError, LockedOutError, auth_service +from src.server.utils.dependencies import optional_auth, security + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/setup", status_code=status.HTTP_201_CREATED) +def setup_auth(req: SetupRequest): + """Initial setup endpoint to configure the master password.""" + if auth_service.is_configured(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Master password already configured", + ) + + try: + auth_service.setup_master_password(req.master_password) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return {"status": "ok"} + + +@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 a real app + identifier = "global" + + try: + valid = auth_service.validate_master_password(req.password, identifier=identifier) + except AuthError as e: + raise HTTPException(status_code=400, detail=str(e)) + except LockedOutError as e: + raise HTTPException(status_code=429, detail=str(e)) + + if not valid: + raise HTTPException(status_code=401, detail="Invalid credentials") + + token = auth_service.create_access_token(subject="master", remember=bool(req.remember)) + return token + + +@router.post("/logout") +def logout(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Logout by revoking token (no-op for stateless JWT).""" + token = credentials.credentials + # Placeholder; auth_service.revoke_token can be expanded to persist revocations + auth_service.revoke_token(token) + return {"status": "ok"} + + +@router.get("/status", response_model=AuthStatus) +def status(auth: Optional[dict] = Depends(optional_auth)): + """Return whether master password is configured and if caller is authenticated.""" + return AuthStatus(configured=auth_service.is_configured(), authenticated=bool(auth)) diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index 98c3783..13d2ec3 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -17,6 +17,7 @@ from src.config.settings import settings # Import core functionality from src.core.SeriesApp import SeriesApp +from src.server.api.auth import router as auth_router from src.server.controllers.error_controller import ( not_found_handler, server_error_handler, @@ -51,6 +52,7 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # Include routers app.include_router(health_router) app.include_router(page_router) +app.include_router(auth_router) # Global variables for application state series_app: Optional[SeriesApp] = None diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index f9c32f6..8c382de 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -17,6 +17,7 @@ except ImportError: from src.config.settings import settings from src.core.SeriesApp import SeriesApp +from src.server.services.auth_service import AuthError, auth_service # Security scheme for JWT authentication security = HTTPBearer() @@ -93,12 +94,22 @@ def get_current_user( Raises: HTTPException: If token is invalid or user is not authenticated """ - # TODO: Implement JWT token validation - # This is a placeholder for authentication implementation - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="Authentication functionality not yet implemented" - ) + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authorization credentials", + ) + + token = credentials.credentials + try: + # Validate and decode token using the auth service + session = auth_service.create_session_model(token) + return session.dict() + except AuthError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) def require_auth( diff --git a/tests/api/test_auth_endpoints.py b/tests/api/test_auth_endpoints.py new file mode 100644 index 0000000..bb2172c --- /dev/null +++ b/tests/api/test_auth_endpoints.py @@ -0,0 +1,44 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from src.server.fastapi_app import app +from src.server.services.auth_service import auth_service + + +@pytest.mark.anyio +async def test_auth_flow_setup_login_status_logout(): + # Ensure not configured at start for test isolation + auth_service._hash = None + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + # Setup + r = await client.post("/api/auth/setup", json={"master_password": "Aa!strong1"}) + assert r.status_code == 201 + + # Bad login + r = await client.post("/api/auth/login", json={"password": "wrong"}) + assert r.status_code == 401 + + # Good login + r = await client.post("/api/auth/login", json={"password": "Aa!strong1"}) + assert r.status_code == 200 + data = r.json() + assert "access_token" in data + + token = data["access_token"] + + # Status unauthenticated when no auth header + r = await client.get("/api/auth/status") + assert r.status_code == 200 + assert r.json()["configured"] is True + + # Status authenticated with header + r = await client.get("/api/auth/status", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["authenticated"] is True + + # Logout + r = await client.post("/api/auth/logout", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 +