M4-T02: add exponential login back-off service and wire into login endpoint
This commit is contained in:
@@ -26,6 +26,9 @@ def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
# Disable throttle by default so existing tests are unaffected; throttle
|
||||
# tests opt in explicitly via their own monkeypatch / env override.
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Tests for M4-T02: login throttle service and endpoint integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.db import reset_db_caches
|
||||
from app.config import get_settings
|
||||
from app.main import create_app
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
from app.services import login_throttle as svc
|
||||
from app.services.login_throttle import (
|
||||
N_FREE,
|
||||
BACKOFF_BASE_S,
|
||||
BACKOFF_CAP_S,
|
||||
_compute_next_allowed_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures — throttle-aware variants (throttle enabled)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic_app.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Fresh migrated DB with throttle enabled (AUTH_LOGIN_THROTTLE_ENABLED=true)."""
|
||||
app_database_path = tmp_path / "throttle_test.db"
|
||||
app_database_url = f"sqlite:///{app_database_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(app_database_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
yield app_database_url
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_session(throttle_db: str) -> Session:
|
||||
"""SQLAlchemy session for the throttle-enabled test DB."""
|
||||
engine = create_engine(throttle_db, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_client(throttle_db: str):
|
||||
"""TestClient wired to a throttle-enabled DB."""
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — service internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeNextAllowedAt:
|
||||
"""Verify the exponential formula independently of DB I/O."""
|
||||
|
||||
def test_no_delay_for_first_n_free_failures(self):
|
||||
now = datetime.now(UTC)
|
||||
for f in range(1, N_FREE + 1):
|
||||
result = _compute_next_allowed_at(f, now)
|
||||
assert result is None, f"Expected None for failures={f}"
|
||||
|
||||
def test_delay_starts_after_n_free(self):
|
||||
now = datetime.now(UTC)
|
||||
result = _compute_next_allowed_at(N_FREE + 1, now)
|
||||
assert result is not None
|
||||
expected_wait = BACKOFF_BASE_S * (2 ** (N_FREE + 1 - N_FREE)) # base * 2^1
|
||||
assert abs((result - now).total_seconds() - expected_wait) < 0.1
|
||||
|
||||
def test_exponential_growth(self):
|
||||
now = datetime.now(UTC)
|
||||
prev_wait = 0.0
|
||||
for failures in range(N_FREE + 1, N_FREE + 8):
|
||||
result = _compute_next_allowed_at(failures, now)
|
||||
assert result is not None
|
||||
wait = (result - now).total_seconds()
|
||||
if prev_wait > 0:
|
||||
# Each step should double (until cap)
|
||||
if wait < BACKOFF_CAP_S:
|
||||
assert abs(wait - prev_wait * 2) < 0.1, (
|
||||
f"Expected doubling at failures={failures}: {prev_wait} → {wait}"
|
||||
)
|
||||
prev_wait = wait
|
||||
|
||||
def test_cap_is_respected(self):
|
||||
now = datetime.now(UTC)
|
||||
# Use a very high failure count to guarantee we're past the cap threshold.
|
||||
result = _compute_next_allowed_at(N_FREE + 20, now)
|
||||
assert result is not None
|
||||
wait = (result - now).total_seconds()
|
||||
assert wait <= BACKOFF_CAP_S + 0.1
|
||||
|
||||
|
||||
class TestRegisterFailureAndCheck:
|
||||
"""Service functions against an in-memory DB."""
|
||||
|
||||
def test_single_failure_within_free_window_no_wait(self, throttle_session: Session):
|
||||
svc.register_failure(throttle_session, ip="1.2.3.4", username="alice")
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="1.2.3.4", username="alice")
|
||||
assert wait == 0, "First failure should not produce any wait"
|
||||
|
||||
def test_free_failures_all_zero_wait(self, throttle_session: Session):
|
||||
for _ in range(N_FREE):
|
||||
svc.register_failure(throttle_session, ip="10.0.0.1", username="bob")
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="10.0.0.1", username="bob")
|
||||
assert wait == 0
|
||||
|
||||
def test_fourth_failure_produces_wait(self, throttle_session: Session):
|
||||
"""failures = N_FREE + 1 should produce a non-zero wait."""
|
||||
for _ in range(N_FREE + 1):
|
||||
svc.register_failure(throttle_session, ip="10.0.0.2", username="carol")
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="10.0.0.2", username="carol")
|
||||
assert wait > 0
|
||||
|
||||
def test_wait_grows_with_more_failures(self, throttle_session: Session):
|
||||
ip, user = "10.0.0.3", "dave"
|
||||
# Accumulate past the free window
|
||||
for _ in range(N_FREE + 1):
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
wait1 = svc.check_and_get_wait(throttle_session, ip=ip, username=user)
|
||||
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
wait2 = svc.check_and_get_wait(throttle_session, ip=ip, username=user)
|
||||
|
||||
assert wait2 > wait1, "Wait should increase with more failures"
|
||||
|
||||
def test_wait_is_capped(self, throttle_session: Session):
|
||||
ip, user = "10.0.0.4", "eve"
|
||||
# Simulate a high failure count by calling register_failure many times.
|
||||
for _ in range(N_FREE + 15):
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
wait = svc.check_and_get_wait(throttle_session, ip=ip, username=user)
|
||||
assert wait <= BACKOFF_CAP_S, f"Wait {wait} exceeds cap {BACKOFF_CAP_S}"
|
||||
|
||||
def test_clear_removes_rows(self, throttle_session: Session):
|
||||
ip, user = "10.0.0.5", "frank"
|
||||
for _ in range(N_FREE + 2):
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
assert svc.check_and_get_wait(throttle_session, ip=ip, username=user) > 0
|
||||
|
||||
svc.clear(throttle_session, ip=ip, username=user)
|
||||
assert svc.check_and_get_wait(throttle_session, ip=ip, username=user) == 0
|
||||
|
||||
def test_clear_is_idempotent(self, throttle_session: Session):
|
||||
"""Calling clear when no rows exist must not raise."""
|
||||
svc.clear(throttle_session, ip="192.168.1.1", username="nobody")
|
||||
assert svc.check_and_get_wait(throttle_session, ip="192.168.1.1", username="nobody") == 0
|
||||
|
||||
def test_dual_key_max_is_used(self, throttle_session: Session):
|
||||
"""If only the username key is in backoff, check returns its wait time."""
|
||||
# Register enough failures for username but use a fresh IP.
|
||||
user = "grace"
|
||||
for _ in range(N_FREE + 1):
|
||||
svc.register_failure(throttle_session, ip="172.16.0.1", username=user)
|
||||
|
||||
# Fresh IP, same username — username key should dominate.
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="172.16.0.2", username=user)
|
||||
assert wait > 0, "Username key should dominate even with a fresh IP"
|
||||
|
||||
def test_expired_window_is_zero(self, throttle_session: Session):
|
||||
"""A row whose next_allowed_at is in the past should yield 0."""
|
||||
now = datetime.now(UTC)
|
||||
past = now - timedelta(seconds=10)
|
||||
row = LoginThrottle(
|
||||
scope="ip",
|
||||
key="192.168.99.1",
|
||||
failures=N_FREE + 2,
|
||||
first_failed_at=past,
|
||||
last_failed_at=past,
|
||||
next_allowed_at=past,
|
||||
)
|
||||
throttle_session.add(row)
|
||||
throttle_session.commit()
|
||||
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="192.168.99.1", username="nobody")
|
||||
assert wait == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — POST /api/auth/login endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bad_login(client: TestClient, *, username: str = "admin", password: str = "wrong") -> int:
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
return resp.status_code
|
||||
|
||||
|
||||
def _good_login(client: TestClient) -> int:
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "test-password"}
|
||||
)
|
||||
return resp.status_code
|
||||
|
||||
|
||||
class TestLoginEndpointThrottle:
|
||||
def test_first_few_failures_return_401(self, throttle_client: TestClient):
|
||||
"""First N_FREE failures should still return 401, not 429."""
|
||||
for _ in range(N_FREE):
|
||||
code = _bad_login(throttle_client)
|
||||
assert code == 401, f"Expected 401 within free window, got {code}"
|
||||
|
||||
def test_429_after_enough_failures(self, throttle_client: TestClient):
|
||||
"""After N_FREE+1 failures the endpoint must return 429."""
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
def test_429_includes_retry_after_header(self, throttle_client: TestClient):
|
||||
"""429 response must carry a Retry-After header with a positive integer."""
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
retry_after = resp.headers.get("retry-after")
|
||||
assert retry_after is not None, "Retry-After header missing"
|
||||
assert int(retry_after) > 0, "Retry-After must be positive"
|
||||
|
||||
def test_correct_password_blocked_in_window(self, throttle_client: TestClient):
|
||||
"""Even a correct password must be rejected (429) while inside backoff window."""
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "test-password"}
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
"Correct password should still be blocked while in backoff window"
|
||||
)
|
||||
|
||||
def test_success_clears_throttle(self, throttle_client: TestClient):
|
||||
"""A successful login must clear the backoff so the next attempt is free."""
|
||||
# Register some failures (but not enough to trigger 429).
|
||||
for _ in range(N_FREE):
|
||||
_bad_login(throttle_client)
|
||||
|
||||
# Successful login clears state.
|
||||
assert _good_login(throttle_client) == 200
|
||||
|
||||
# Subsequent bad attempts should be counted fresh (no residual backoff).
|
||||
code = _bad_login(throttle_client)
|
||||
assert code == 401, "After clear, first new failure should be 401 not 429"
|
||||
|
||||
def test_401_uses_generic_message(self, throttle_client: TestClient):
|
||||
"""401 detail must be generic — must not reveal whether the user exists."""
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "nonexistent", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "invalid username or password" == detail
|
||||
assert "user" not in detail.lower() or "username" in detail.lower()
|
||||
# Must NOT say things like "user not found" or "user does not exist"
|
||||
assert "not found" not in detail
|
||||
assert "does not exist" not in detail
|
||||
|
||||
def test_unknown_user_401_has_same_message(self, throttle_client: TestClient):
|
||||
"""Unknown username and wrong password for known user give identical 401 detail."""
|
||||
resp_known = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
resp_unknown = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "nobody", "password": "wrong"}
|
||||
)
|
||||
assert resp_known.status_code == 401
|
||||
assert resp_unknown.status_code == 401
|
||||
assert resp_known.json()["detail"] == resp_unknown.json()["detail"]
|
||||
|
||||
def test_retry_after_grows_with_more_failures(self, throttle_client: TestClient):
|
||||
"""Retry-After should be strictly larger after more failures (exponential)."""
|
||||
# Accumulate past the free window.
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp1 = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp1.status_code == 429
|
||||
wait1 = int(resp1.headers["retry-after"])
|
||||
|
||||
# One more failure → longer wait.
|
||||
_bad_login(throttle_client)
|
||||
resp2 = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp2.status_code == 429
|
||||
wait2 = int(resp2.headers["retry-after"])
|
||||
|
||||
assert wait2 >= wait1, "Retry-After should not decrease with more failures"
|
||||
|
||||
def test_xff_header_used_when_trust_enabled(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""When AUTH_TRUST_FORWARDED_FOR=true the leftmost XFF IP is used as the IP key."""
|
||||
app_database_path = tmp_path / "xff_test.db"
|
||||
app_database_url = f"sqlite:///{app_database_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true")
|
||||
monkeypatch.setenv("AUTH_TRUST_FORWARDED_FOR", "true")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(app_database_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
# Accumulate failures from XFF IP "5.5.5.5".
|
||||
for _ in range(N_FREE + 1):
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "5.5.5.5, 10.0.0.1"},
|
||||
)
|
||||
|
||||
# Same XFF IP should now be in backoff.
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "5.5.5.5, 10.0.0.1"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
# Different XFF IP should NOT be in backoff (username might be, so use fresh one).
|
||||
resp2 = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "6.6.6.6"},
|
||||
)
|
||||
# This IP has no failures, but username "admin" DOES have N_FREE+1 failures.
|
||||
# The username key is in backoff, so this should still 429.
|
||||
# To cleanly test IP isolation we'd need a different username — but single admin
|
||||
# means we can only confirm the XFF IP is being read (not defaulting to socket).
|
||||
# The important thing: the request was processed with XFF logic (no crash).
|
||||
assert resp2.status_code in (401, 429)
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Throttle disabled — no-op behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThrottleDisabled:
|
||||
"""When AUTH_LOGIN_THROTTLE_ENABLED=false the endpoint behaves exactly as before."""
|
||||
|
||||
def test_many_failures_still_return_401_not_429(self, client: TestClient):
|
||||
"""With throttle off, no number of failures should produce 429."""
|
||||
# The default `client` fixture has throttle disabled (AUTH_LOGIN_THROTTLE_ENABLED=false).
|
||||
for _ in range(N_FREE + 5):
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 401, f"Expected 401 with throttle off, got {resp.status_code}"
|
||||
|
||||
def test_no_throttle_rows_written_when_disabled(
|
||||
self, auth_database, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""With throttle disabled, no LoginThrottle rows are created."""
|
||||
db_url = auth_database["app_url"]
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
for _ in range(N_FREE + 2):
|
||||
client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine, autoflush=False, autocommit=False, class_=Session
|
||||
)
|
||||
with SessionLocal() as session:
|
||||
count = session.query(LoginThrottle).count()
|
||||
engine.dispose()
|
||||
assert count == 0, f"Expected 0 throttle rows with throttle disabled, found {count}"
|
||||
Reference in New Issue
Block a user