phase: 81_login_rate_limit

All gates verified. The phase is complete — everything was already implemented by prior task runs; this pass verified each completion criterion end-to-end and found no defects.

**Phase 81 final verification report** (tasks 01–03 all present in `complete/`; no code changes needed this pass)

- Verified: `app/core/rate_limit.py` (stdlib sliding window, fail-open) + both login routes' 429 pre-check/record/reset wiring + all unit/integration pins present (11th-429, blocked-success-still-429, clean-counter reset, shared counter, autouse `clean_rate_limit` fixture documented).
- Live check (task 03): dev server + 11 rapid wrong logins → `401 ×10, 429`; 429 body carries `retry-after: 900` + generic detail; server restarted (per-process counter cleared by design).
- `uv run pytest tests/unit/test_rate_limit.py -v --no-cov` → 10 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 1652 passed, **TOTAL 99%** (>90%; rate_limit.py 100%, auth.py 100%)
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolation) → 3 passed
- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings
- Completion criteria: all met, except commit + phase-dir move — per harness rules I left all changes uncommitted in the working tree (harness commits atomically and moves the phase).
- Diff scope: exactly `app/core/rate_limit.py`, `app/api/auth.py`, `tests/unit/test_rate_limit.py`, `tests/integration/test_auth_api.py` + phase files; `pyproject.toml` / `uv.lock` / `frontend/` untouched.
- Deviations: none in code; commit/move deferred to harness as instructed.
- Next pending phase: `82_security_headers`.
This commit is contained in:
2026-09-07 23:08:46 -04:00
parent 894637108c
commit 42a4222949
32 changed files with 1439 additions and 2 deletions
+41 -2
View File
@@ -2,11 +2,16 @@
* ``POST /api/login`` — 204 + signed session cookie on success; 401
``invalid password`` on any mismatch (constant-time, one generic
message, no session set).
message, no session set); while the per-IP failure window is exhausted
→ 429 + ``Retry-After``, one generic detail — audit SEC-03.
* ``POST /api/token-auth`` — 204 + signed session cookie for a valid,
unrevoked API token (PUBLIC — it is the token holders' login route);
every failure shape (malformed / unknown / revoked / empty) is ONE
generic 401 ``invalid token`` (no enumeration, the phase-16 pattern).
generic 401 ``invalid token`` (no enumeration, the phase-16 pattern);
while the per-IP failure window is exhausted → 429 + ``Retry-After``,
one generic detail — audit SEC-03 (the 429 counter is SHARED with
``/api/login``: any auth failure from an IP counts, a success on
either route resets it).
* ``POST /api/logout`` — 204; clears the session and expires the
cookie (idempotent for anonymous callers — one logout wipes BOTH
roles, the session is one dict).
@@ -26,6 +31,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import rate_limit
from app.core import tokens as token_service
from app.core.auth import (
ADMIN_SESSION_KEY,
@@ -38,6 +44,10 @@ from app.core.auth import (
from app.db import get_db
from app.schemas import LoginRequest, TokenAuthRequest, WhoamiResponse
#: One generic 429 detail shared by BOTH throttled login routes (audit
#: SEC-03): no enumeration between a login failure and a token failure.
TOO_MANY_DETAIL = "too many failed sign-in attempts — try again later"
router = APIRouter(tags=["auth"])
@@ -49,11 +59,27 @@ def login(payload: LoginRequest, request: Request) -> Response:
12 h default lifetime). Failure: one generic 401 — the admin count is
one, so there is nothing else to leak, and a wrong password must not
set any session state.
Rate limiting (audit SEC-03): while the per-IP failure window is
exhausted (10 misses in 15 min, shared with ``/api/token-auth``),
the pre-check fires BEFORE the password is compared — 429 +
``Retry-After``, one generic detail; even a correct password stays
429 until the window slides. A success on a CLEAN counter resets it;
a failure is recorded on the 401 path.
"""
ip = request.client.host if request.client is not None else "unknown"
if (wait := rate_limit.remaining_wait(ip)) > 0:
raise HTTPException(
status_code=429,
detail=TOO_MANY_DETAIL,
headers={"Retry-After": str(wait)},
)
settings = get_settings()
if check_password(payload.password, settings.admin_password):
sign_in(request.session)
rate_limit.reset(ip)
return Response(status_code=204)
rate_limit.record_failure(ip)
raise HTTPException(status_code=401, detail="invalid password")
@@ -77,15 +103,28 @@ def token_auth(
generic 401 ``invalid token``: the lookup is by hash (the service
returns ``None`` for anything that is not an active row), so there
is no enumeration surface (the phase-16 pattern).
Rate limiting (audit SEC-03): identical bookkeeping to ``/api/login``
on the SAME per-IP counter — pre-check → 429 + ``Retry-After`` while
the window is exhausted; failures recorded, a success resets.
"""
ip = request.client.host if request.client is not None else "unknown"
if (wait := rate_limit.remaining_wait(ip)) > 0:
raise HTTPException(
status_code=429,
detail=TOO_MANY_DETAIL,
headers={"Retry-After": str(wait)},
)
token = payload.token.strip()
row = token_service.find_active_by_token(db, token) if token else None
if row is None:
rate_limit.record_failure(ip)
raise HTTPException(status_code=401, detail="invalid token")
token_service.mark_used(row)
db.commit()
request.session[USER_SESSION_KEY] = True
request.session[USER_TOKEN_ID_KEY] = str(row.id)
rate_limit.reset(ip)
return Response(status_code=204)