M4-T02: add exponential login back-off service and wire into login endpoint
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user