api(auth): add auth endpoints (setup, login, logout, status), tests, and dependency token decoding; update docs

This commit is contained in:
Lukas 2025-10-13 00:12:35 +02:00
parent aec6357dcb
commit 97bef2c98a
6 changed files with 126 additions and 14 deletions

View File

@ -248,6 +248,7 @@ All endpoints tested and confirmed working:
- Health: `/health` → Returns `{"status": "healthy", ...}` - Health: `/health` → Returns `{"status": "healthy", ...}`
- Root: `/` → Serves main application page - Root: `/` → Serves main application page
- Setup: `/setup` → Serves setup page - Setup: `/setup` → Serves setup page
- Auth API: `/api/auth/*` → Endpoints for setup, login, logout and status (JWT-based)
- Login: `/login` → Serves login page - Login: `/login` → Serves login page
- Queue: `/queue` → Serves download queue page - Queue: `/queue` → Serves download queue page

View File

@ -54,14 +54,6 @@ attempt tracking with temporary lockout, and basic password strength
checks. For persistence of the master password hash and token revocation checks. For persistence of the master password hash and token revocation
we recommend adding a config store or database in a follow-up task. 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 authentication middleware
- []Create `src/server/middleware/auth.py` - []Create `src/server/middleware/auth.py`

62
src/server/api/auth.py Normal file
View File

@ -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))

View File

@ -17,6 +17,7 @@ from src.config.settings import settings
# Import core functionality # Import core functionality
from src.core.SeriesApp import SeriesApp from src.core.SeriesApp import SeriesApp
from src.server.api.auth import router as auth_router
from src.server.controllers.error_controller import ( from src.server.controllers.error_controller import (
not_found_handler, not_found_handler,
server_error_handler, server_error_handler,
@ -51,6 +52,7 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Include routers # Include routers
app.include_router(health_router) app.include_router(health_router)
app.include_router(page_router) app.include_router(page_router)
app.include_router(auth_router)
# Global variables for application state # Global variables for application state
series_app: Optional[SeriesApp] = None series_app: Optional[SeriesApp] = None

View File

@ -17,6 +17,7 @@ except ImportError:
from src.config.settings import settings from src.config.settings import settings
from src.core.SeriesApp import SeriesApp from src.core.SeriesApp import SeriesApp
from src.server.services.auth_service import AuthError, auth_service
# Security scheme for JWT authentication # Security scheme for JWT authentication
security = HTTPBearer() security = HTTPBearer()
@ -93,11 +94,21 @@ def get_current_user(
Raises: Raises:
HTTPException: If token is invalid or user is not authenticated HTTPException: If token is invalid or user is not authenticated
""" """
# TODO: Implement JWT token validation if not credentials:
# This is a placeholder for authentication implementation
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication functionality not yet implemented" 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),
) )

View File

@ -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