From e480a84a445fdc36d14f4369c6a727060c9c9aae Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 21 Jun 2026 21:20:28 +0200 Subject: [PATCH] M4-T02: add exponential login back-off service and wire into login endpoint --- app/api/routes/api/session.py | 42 ++- app/config.py | 2 + app/services/config_page.py | 8 + app/services/login_throttle.py | 179 +++++++++++++ docs/design/m4-login-hardening.md | 2 +- openapi/openapi.json | 2 +- openapi/openapi.yaml | 2 + tests/conftest.py | 3 + tests/test_login_throttle.py | 417 ++++++++++++++++++++++++++++++ 9 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 app/services/login_throttle.py create mode 100644 tests/test_login_throttle.py diff --git a/app/api/routes/api/session.py b/app/api/routes/api/session.py index 6592c55..58c1ecf 100644 --- a/app/api/routes/api/session.py +++ b/app/api/routes/api/session.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.orm import Session from app.api.routes.api.deps import require_csrf, require_session @@ -22,6 +22,7 @@ from app.services.auth import ( create_session, revoke_session, ) +from app.services import login_throttle logger = logging.getLogger(__name__) @@ -46,9 +47,26 @@ def get_session( return _build_session_response(auth) +def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str: + """Extract the client IP address from the request. + + When ``trust_forwarded_for`` is True (reverse-proxy deployments) the + left-most value from the ``X-Forwarded-For`` header is used. Otherwise the + direct socket IP (``request.client.host``) is used. + """ + if trust_forwarded_for: + xff = request.headers.get("X-Forwarded-For", "") + if xff: + return xff.split(",")[0].strip() + if request.client is not None: + return request.client.host + return "unknown" + + @router.post("/auth/login", response_model=SessionResponse) def post_login( body: LoginRequest, + request: Request, response: Response, db: Session = Depends(get_db), settings: Settings = Depends(get_app_settings), @@ -58,15 +76,37 @@ def post_login( On success, sets an HttpOnly session cookie and returns the session user + CSRF token. On failure, returns 401 with no cookie set. + Repeated failures trigger exponential back-off (429 + Retry-After). No X-CSRF-Token required (unauthenticated endpoint). """ + client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for) + + # --- Throttle check (before any password verification) --- + if settings.auth_login_throttle_enabled: + wait_seconds = login_throttle.check_and_get_wait( + db, ip=client_ip, username=body.username + ) + if wait_seconds > 0: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="too many failed login attempts; please try again later", + headers={"Retry-After": str(wait_seconds)}, + ) + + # --- Password verification --- user = authenticate_user(db, username=body.username, password=body.password) if user is None: + if settings.auth_login_throttle_enabled: + login_throttle.register_failure(db, ip=client_ip, username=body.username) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password", ) + # --- Success: clear back-off state and issue session --- + if settings.auth_login_throttle_enabled: + login_throttle.clear(db, ip=client_ip, username=body.username) + auth_session, raw_token = create_session(db, user=user, settings=settings) logger.info("Created API authenticated session for user '%s'", user.username) diff --git a/app/config.py b/app/config.py index 7140b61..61fbbe9 100644 --- a/app/config.py +++ b/app/config.py @@ -37,6 +37,8 @@ class Settings(BaseSettings): auth_session_cookie_name: str = "home_automation_session" auth_session_ttl_hours: int = 12 auth_cookie_secure_override: bool | None = True + auth_login_throttle_enabled: bool = True + auth_trust_forwarded_for: bool = False model_config = SettingsConfigDict( env_file=".env", diff --git a/app/services/config_page.py b/app/services/config_page.py index 4deaaf4..c39d4be 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -49,6 +49,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( "auth_cookie_secure_override", "Cookie Secure Override", ), + ConfigField( + "Authentication", + "AUTH_LOGIN_THROTTLE_ENABLED", + "auth_login_throttle_enabled", + "Login Throttle Enabled", + ), ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True), ConfigField( "Poo", @@ -284,4 +290,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]: "auth_session_cookie_name": settings.auth_session_cookie_name, "auth_session_ttl_hours": settings.auth_session_ttl_hours, "auth_cookie_secure_override": settings.auth_cookie_secure_override, + "auth_login_throttle_enabled": settings.auth_login_throttle_enabled, + "auth_trust_forwarded_for": settings.auth_trust_forwarded_for, } diff --git a/app/services/login_throttle.py b/app/services/login_throttle.py new file mode 100644 index 0000000..8b66862 --- /dev/null +++ b/app/services/login_throttle.py @@ -0,0 +1,179 @@ +"""Exponential back-off throttle service for the login endpoint. + +Design +------ +Failures are tracked per (scope, key) in the ``auth_login_throttle`` table: + +* scope ``'ip'`` → key is the client IP address +* scope ``'user'`` → key is the username + +On each login attempt the endpoint checks **both** keys and takes the **maximum** +remaining wait (in seconds). A wait > 0 means the caller is still inside a +back-off window and should receive 429. + +Exponential formula +------------------- +The first ``N_FREE`` failures are free (no delay). After that:: + + wait = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (failures - N_FREE)) + +Constants (module-level so they can be tuned without touching the algorithm): + +* ``N_FREE`` = 3 — failures before any delay starts +* ``BACKOFF_BASE_S`` = 1 — base wait in seconds (1st delayed attempt → 2 s) +* ``BACKOFF_CAP_S`` = 900 — hard cap (15 min) + +Example delay schedule: + failures 1, 2, 3 → 0 s (free) + failures 4 → 2 s + failures 5 → 4 s + failures 6 → 8 s + failures 7 → 16 s + … + failures 13+ → 900 s (capped) + +Timezone notes +-------------- +The model uses ``DateTime(timezone=True)``. SQLite stores timestamps without +timezone info, so when rows are read back the column value may be timezone-naive. +We normalise every stored value to UTC-aware via ``_as_utc`` before comparing +against ``datetime.now(UTC)``. +""" + +from __future__ import annotations + +import math +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.models.auth_throttle import LoginThrottle + +# --------------------------------------------------------------------------- +# Tunable constants +# --------------------------------------------------------------------------- + +N_FREE: int = 3 # failures before any backoff starts +BACKOFF_BASE_S: int = 1 # seconds — wait after the first delayed failure +BACKOFF_CAP_S: int = 900 # seconds — maximum wait (15 min) + +# Internal scope labels (keep in sync with model constraint) +_SCOPE_IP = "ip" +_SCOPE_USER = "user" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def check_and_get_wait(session: Session, *, ip: str, username: str) -> int: + """Return the number of seconds the caller must still wait, or 0 if allowed. + + Checks both the IP row and the username row; returns the *larger* remaining + wait so that both keys must have their back-off window expire before the + caller is permitted to try again. + """ + now = datetime.now(UTC) + wait_ip = _remaining_wait(session, scope=_SCOPE_IP, key=ip, now=now) + wait_user = _remaining_wait(session, scope=_SCOPE_USER, key=username, now=now) + return max(wait_ip, wait_user) + + +def register_failure(session: Session, *, ip: str, username: str) -> None: + """Record one login failure for both the IP and the username keys. + + Updates (or creates) the throttle row for each key, increments the failure + counter, and recomputes ``next_allowed_at`` using the exponential formula. + """ + now = datetime.now(UTC) + _upsert_failure(session, scope=_SCOPE_IP, key=ip, now=now) + _upsert_failure(session, scope=_SCOPE_USER, key=username, now=now) + session.commit() + + +def clear(session: Session, *, ip: str, username: str) -> None: + """Delete the throttle rows for the given IP and username (successful login). + + It is safe to call this even if no rows exist for the given keys. + """ + session.execute( + delete(LoginThrottle).where( + (LoginThrottle.scope == _SCOPE_IP) & (LoginThrottle.key == ip) + | (LoginThrottle.scope == _SCOPE_USER) & (LoginThrottle.key == username) + ) + ) + session.commit() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _remaining_wait(session: Session, *, scope: str, key: str, now: datetime) -> int: + """Return the number of seconds that ``key`` in ``scope`` must still wait. + + Returns 0 if the key has no throttle row or if the back-off window has + already expired. + """ + row = session.scalar( + select(LoginThrottle) + .where(LoginThrottle.scope == scope, LoginThrottle.key == key) + .limit(1) + ) + if row is None or row.next_allowed_at is None: + return 0 + + next_allowed = _as_utc(row.next_allowed_at) + if next_allowed <= now: + return 0 + + remaining = (next_allowed - now).total_seconds() + # Round up so callers are never told "0 seconds" while still blocked. + return math.ceil(remaining) + + +def _compute_next_allowed_at(failures: int, now: datetime) -> datetime | None: + """Compute the earliest time the key should be allowed to try again. + + Returns ``None`` for the first ``N_FREE`` failures (no delay). + """ + if failures <= N_FREE: + return None + wait_s = min(BACKOFF_CAP_S, BACKOFF_BASE_S * (2 ** (failures - N_FREE))) + return now + timedelta(seconds=wait_s) + + +def _upsert_failure(session: Session, *, scope: str, key: str, now: datetime) -> None: + """Create or update the throttle row for (scope, key) with one more failure.""" + row = session.scalar( + select(LoginThrottle) + .where(LoginThrottle.scope == scope, LoginThrottle.key == key) + .limit(1) + ) + if row is None: + new_failures = 1 + row = LoginThrottle( + scope=scope, + key=key, + failures=new_failures, + first_failed_at=now, + last_failed_at=now, + next_allowed_at=_compute_next_allowed_at(new_failures, now), + ) + session.add(row) + else: + row.failures += 1 + row.last_failed_at = now + row.next_allowed_at = _compute_next_allowed_at(row.failures, now) + + +def _as_utc(value: datetime | None) -> datetime | None: + """Normalise a possibly-naive datetime to UTC-aware.""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/docs/design/m4-login-hardening.md b/docs/design/m4-login-hardening.md index 17abdd3..53e87ed 100644 --- a/docs/design/m4-login-hardening.md +++ b/docs/design/m4-login-hardening.md @@ -157,7 +157,7 @@ Phase B(TOTP,可选配) - **Reviewer checklist**: `(scope,key)` 唯一;链上单 head;env.py 注册模型。 ### M4-T02 — 退避 service + 接入登录 -- **Status**: `todo` · **Depends**: M4-T01 +- **Status**: `done` · **Depends**: M4-T01 - **Context**: 指数退避核心 + 接 `POST /api/auth/login`。 - **Files**: `create app/services/login_throttle.py`;`modify app/api/routes/api/session.py`、`app/services/config_page.py`(+`CONFIG_FIELDS` `AUTH_LOGIN_THROTTLE_ENABLED`)、`app/config.py`(+`auth_login_throttle_enabled`、`auth_trust_forwarded_for`);`modify tests/test_api_session.py`、`create tests/test_login_throttle.py` - **Steps**: diff --git a/openapi/openapi.json b/openapi/openapi.json index d3a2fd2..94981ce 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -653,7 +653,7 @@ "api-session" ], "summary": "Post Login", - "description": "Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nNo X-CSRF-Token required (unauthenticated endpoint).", + "description": "Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nRepeated failures trigger exponential back-off (429 + Retry-After).\nNo X-CSRF-Token required (unauthenticated endpoint).", "operationId": "post_login_api_auth_login_post", "requestBody": { "content": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 6ce99b3..423f543 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -480,6 +480,8 @@ paths: On failure, returns 401 with no cookie set. + Repeated failures trigger exponential back-off (429 + Retry-After). + No X-CSRF-Token required (unauthenticated endpoint).' operationId: post_login_api_auth_login_post requestBody: diff --git a/tests/conftest.py b/tests/conftest.py index 55537f8..92a3727 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_login_throttle.py b/tests/test_login_throttle.py new file mode 100644 index 0000000..6c1cd66 --- /dev/null +++ b/tests/test_login_throttle.py @@ -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}"