feat(auth): throttle password login attempts

This commit is contained in:
2026-07-21 12:23:12 +02:00
parent e91935c03a
commit 8c74e360d2
6 changed files with 666 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
@@ -51,6 +52,11 @@ from govoplan_access.backend.semantic import collect_function_assignment_ids, co
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
from govoplan_access.backend.security.login_throttle import (
LoginThrottle,
LoginThrottleDecision,
build_login_throttle,
)
from govoplan_access.backend.security.sessions import (
authenticate_session_token,
collect_user_authorization_context,
@@ -277,6 +283,67 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
return account, row[0], row[1]
@lru_cache(maxsize=1)
def _configured_login_throttle() -> LoginThrottle | None:
if not settings.auth_login_throttle_enabled:
return None
return build_login_throttle(
redis_url=settings.redis_url,
identity_limit=settings.auth_login_throttle_identity_limit,
client_limit=settings.auth_login_throttle_client_limit,
window_seconds=settings.auth_login_throttle_window_seconds,
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
)
def _raise_login_throttled(decision: LoginThrottleDecision) -> None:
retry_after = max(1, decision.retry_after_seconds)
# The detail deliberately matches every credential failure. Status and
# Retry-After communicate endpoint throttling without disclosing whether
# the supplied identity exists or has an active membership.
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Invalid login",
headers={"Retry-After": str(retry_after)},
)
def _resolve_throttled_login_user(
session: Session,
payload: LoginRequest,
request: Request,
) -> tuple[Account, User, Tenant]:
throttle = _configured_login_throttle()
if throttle is None:
return _resolve_login_user(session, payload)
normalized_email = normalize_email(payload.email)
client_address = request.client.host if request.client else None
context = {
"normalized_email": normalized_email,
"tenant_slug": payload.tenant_slug,
"client_address": client_address,
}
decision = throttle.check(**context)
if not decision.allowed:
_raise_login_throttled(decision)
try:
resolved = _resolve_login_user(session, payload)
except HTTPException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
decision = throttle.record_failure(**context)
if not decision.allowed:
_raise_login_throttled(decision)
raise
throttle.record_success(
normalized_email=normalized_email,
tenant_slug=payload.tenant_slug,
)
return resolved
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
x_api_key = request.headers.get("x-api-key")
if x_api_key:
@@ -620,7 +687,7 @@ def _me_response(
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload)
account, user, tenant = _resolve_throttled_login_user(session, payload, request)
identity_directory = _identity_directory_from_request(request)
authorization_context = collect_user_authorization_context(
session,