Harden auth login against brute-force and refresh security docs
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user