M4-T02: add exponential login back-off service and wire into login endpoint

This commit is contained in:
2026-06-21 21:20:28 +02:00
parent 64d3882c16
commit e480a84a44
9 changed files with 654 additions and 3 deletions
+41 -1
View File
@@ -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)
+2
View File
@@ -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",
+8
View File
@@ -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,
}
+179
View File
@@ -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)