Harden auth login against brute-force and refresh security docs

This commit is contained in:
2026-03-01 18:24:26 -03:00
parent 9cbbd80f47
commit 4c27fd6483
12 changed files with 715 additions and 3 deletions

View File

@@ -1,5 +1,7 @@
"""Authentication endpoints for credential login, session introspection, and logout."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
@@ -12,10 +14,19 @@ from app.schemas.auth import (
AuthSessionResponse,
AuthUserResponse,
)
from app.services.auth_login_throttle import (
check_login_throttle,
clear_login_throttle,
record_failed_login_attempt,
)
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
LOGIN_THROTTLED_DETAIL = "Too many login attempts. Try again later."
LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL = "Login rate limiter backend unavailable"
def _request_ip_address(request: Request) -> str | None:
@@ -31,13 +42,37 @@ def _request_user_agent(request: Request) -> str | None:
return user_agent[:512] if user_agent else None
def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]:
"""Returns a bounded Retry-After header payload for throttled authentication responses."""
return {"Retry-After": str(max(1, int(retry_after_seconds)))}
@router.post("/login", response_model=AuthLoginResponse)
def login(
payload: AuthLoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> AuthLoginResponse:
"""Authenticates username and password and returns an issued bearer session token."""
"""Authenticates credentials with throttle protection and returns an issued bearer session token."""
ip_address = _request_ip_address(request)
try:
throttle_status = check_login_throttle(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
) from error
if throttle_status.is_throttled:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=LOGIN_THROTTLED_DETAIL,
headers=_retry_after_headers(throttle_status.retry_after_seconds),
)
user = authenticate_user(
session,
@@ -45,16 +80,44 @@ def login(
password=payload.password,
)
if user is None:
try:
lockout_seconds = record_failed_login_attempt(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
) from error
if lockout_seconds > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=LOGIN_THROTTLED_DETAIL,
headers=_retry_after_headers(lockout_seconds),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
try:
clear_login_throttle(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError:
logger.warning(
"Failed to clear login throttle state after successful authentication: username=%s ip=%s",
payload.username.strip().lower(),
ip_address or "",
)
issued_session = issue_user_session(
session,
user=user,
user_agent=_request_user_agent(request),
ip_address=_request_ip_address(request),
ip_address=ip_address,
)
session.commit()
return AuthLoginResponse(