This commit enforces the repository boundary by eliminating direct database connection dependencies (DbDep) from all routers. Routers now depend on service context dependencies that combine the database connection with the related repositories. Changes: - Add 5 service context dependencies in dependencies.py: * SessionServiceContext: db + session_repo * BlocklistServiceContext: db + blocklist_repo + import_log_repo + settings_repo * SettingsServiceContext: db + settings_repo * BanServiceContext: db + fail2ban_db_repo * HistoryServiceContext: db + fail2ban_db_repo + history_archive_repo - Refactor all 9 routers (auth, bans, blocklist, config_misc, dashboard, geo, history, jails, setup) to use service contexts instead of DbDep. - Update Backend-Development.md with clear examples of the new pattern and documentation of available service contexts. Rationale: - Enforces the repository boundary through the dependency system - Makes database operations explicit and auditable - Improves testability by allowing service contexts to be mocked - Prevents accidental direct database access from routers The deprecated DbDep remains available for backward compatibility with services that have not yet been refactored, but routers can no longer import it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
215 lines
7.2 KiB
Python
215 lines
7.2 KiB
Python
"""Authentication router.
|
|
|
|
``POST /api/auth/login`` — verify master password and issue a session.
|
|
``POST /api/auth/logout`` — revoke the current session.
|
|
|
|
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie for
|
|
browser-based SPAs. The cookie is automatically included in all requests
|
|
and is inaccessible to JavaScript, protecting it from XSS attacks and
|
|
malicious scripts.
|
|
|
|
For programmatic API clients (non-browser), use ``POST /api/auth/token``
|
|
which returns a token in the response body for use in the ``Authorization``
|
|
header. This endpoint does not set a cookie.
|
|
|
|
Login attempts are rate-limited to 5 per minute per IP address to prevent
|
|
brute-force attacks. Requests exceeding the limit return ``429 Too Many Requests``
|
|
with a ``Retry-After`` header.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
|
|
|
from app.dependencies import (
|
|
AuthDep,
|
|
LoginRateLimiterDep,
|
|
SessionCacheDep,
|
|
SessionServiceContextDep,
|
|
SettingsDep,
|
|
)
|
|
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
|
|
from app.services import auth_service
|
|
from app.utils.client_ip import get_client_ip
|
|
from app.utils.constants import SESSION_COOKIE_NAME
|
|
|
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
# Trusted proxy IPs that can set X-Forwarded-For header.
|
|
# By default, none are trusted. In production behind nginx, add the nginx container IP.
|
|
_TRUSTED_PROXIES: list[str] = []
|
|
|
|
|
|
@router.post(
|
|
"/login",
|
|
response_model=LoginResponse,
|
|
summary="Authenticate with the master password",
|
|
)
|
|
async def login(
|
|
body: LoginRequest,
|
|
response: Response,
|
|
request: Request,
|
|
session_ctx: SessionServiceContextDep,
|
|
settings: SettingsDep,
|
|
rate_limiter: LoginRateLimiterDep,
|
|
) -> LoginResponse:
|
|
"""Verify the master password and return a session token.
|
|
|
|
On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
|
cookie so the browser SPA benefits from automatic credential handling.
|
|
|
|
Rate limiting: Up to 5 login attempts per minute per client IP.
|
|
Requests exceeding this limit return ``429 Too Many Requests`` with
|
|
a ``Retry-After`` header.
|
|
|
|
Args:
|
|
body: Login request validated by Pydantic.
|
|
response: FastAPI response object used to set the cookie.
|
|
request: The incoming HTTP request (used to extract client IP).
|
|
session_ctx: Session service context containing db and repository.
|
|
settings: Application settings (used for session duration).
|
|
rate_limiter: The login rate limiter (per IP).
|
|
|
|
Returns:
|
|
:class:`~app.models.auth.LoginResponse` containing the token.
|
|
|
|
Raises:
|
|
HTTPException: 401 if the password is incorrect.
|
|
HTTPException: 429 if the rate limit is exceeded.
|
|
"""
|
|
client_ip = get_client_ip(request, trusted_proxies=_TRUSTED_PROXIES)
|
|
|
|
if not rate_limiter.is_allowed(client_ip):
|
|
log.warning("login_rate_limit_exceeded", client_ip=client_ip)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Too many login attempts. Please try again later.",
|
|
headers={"Retry-After": "60"},
|
|
)
|
|
|
|
try:
|
|
signed_token, expires_at = await auth_service.login(
|
|
session_ctx.db,
|
|
password=body.password,
|
|
session_duration_minutes=settings.session_duration_minutes,
|
|
session_secret=settings.session_secret,
|
|
session_repo=session_ctx.session_repo,
|
|
)
|
|
except ValueError as exc:
|
|
# Add delay on wrong password to slow down brute-force attacks.
|
|
# The bcrypt checkpw already takes ~100ms at cost factor 12,
|
|
# but an extra 10 seconds makes automation much less feasible.
|
|
await asyncio.sleep(10.0)
|
|
log.warning("login_failed", client_ip=client_ip, error=str(exc))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=signed_token,
|
|
httponly=settings.session_cookie_httponly,
|
|
samesite=settings.session_cookie_samesite,
|
|
secure=settings.session_cookie_secure,
|
|
max_age=settings.session_duration_minutes * 60,
|
|
)
|
|
log.info("login_success", client_ip=client_ip)
|
|
return LoginResponse(expires_at=expires_at)
|
|
|
|
|
|
@router.get(
|
|
"/session",
|
|
summary="Validate the current session",
|
|
)
|
|
async def validate_session(
|
|
_: AuthDep,
|
|
) -> dict[str, bool]:
|
|
"""Validate the current session.
|
|
|
|
This endpoint requires a valid session and returns 200 if the session is
|
|
valid and still active. If the session is invalid, expired, or missing,
|
|
FastAPI's ``require_auth`` dependency returns 401 automatically.
|
|
|
|
The frontend calls this on mount to bootstrap its authentication state
|
|
from the backend rather than relying solely on cached ``sessionStorage``.
|
|
|
|
Args:
|
|
_: The injected session object (unused, but its presence triggers validation).
|
|
|
|
Returns:
|
|
A simple JSON object confirming the session is valid.
|
|
"""
|
|
return {"valid": True}
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
response_model=LogoutResponse,
|
|
summary="Revoke the current session",
|
|
)
|
|
async def logout(
|
|
request: Request,
|
|
response: Response,
|
|
session_ctx: SessionServiceContextDep,
|
|
settings: SettingsDep,
|
|
session_cache: SessionCacheDep,
|
|
) -> LogoutResponse:
|
|
"""Invalidate the active session.
|
|
|
|
The session token is read from the ``bangui_session`` cookie or the
|
|
``Authorization: Bearer`` header. If no token is present the request
|
|
is silently treated as a successful logout (idempotent).
|
|
|
|
Args:
|
|
request: FastAPI request (used to extract the token).
|
|
response: FastAPI response (used to clear the cookie).
|
|
session_ctx: Session service context containing db and repository.
|
|
settings: Application settings (used to unwrap signed tokens).
|
|
session_cache: Session cache for invalidation.
|
|
|
|
Returns:
|
|
:class:`~app.models.auth.LogoutResponse`.
|
|
"""
|
|
token = _extract_token(request)
|
|
if token:
|
|
raw_token = await auth_service.logout(
|
|
session_ctx.db,
|
|
token,
|
|
settings.session_secret,
|
|
session_repo=session_ctx.session_repo,
|
|
)
|
|
if raw_token:
|
|
session_cache.invalidate(raw_token)
|
|
session_cache.invalidate(token)
|
|
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
|
return LogoutResponse()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extract_token(request: Request) -> str | None:
|
|
"""Extract the session token from cookie or Authorization header.
|
|
|
|
Args:
|
|
request: The incoming FastAPI request.
|
|
|
|
Returns:
|
|
The token string, or ``None`` if absent.
|
|
"""
|
|
token: str | None = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if token:
|
|
return token
|
|
auth_header: str = request.headers.get("Authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
return auth_header[len("Bearer "):]
|
|
return None
|