## Implementation Plan: Add Per-Email Rate Limiting for Login Endpoint ### Summary The investigation confirms that the login endpoint lacks per-credential (email) rate limiting. The existing per‑IP limiter (default 60/min) is too high to block the reported 20‑attempt burst, and distributed attacks across multiple IPs bypass it entirely. This plan adds a sliding‑window counter per email address, enforced **inside the login handler** after each failed authentication attempt. The threshold is configurable via an environment variable (`DEVPLACE_LOGIN_EMAIL_RATE_LIMIT`, default 10 attempts per minute per email). The existing per‑IP middleware remains unchanged. ### Files to Modify #### 1. `devplacepy/main.py` - Add a new configuration constant: ```python LOGIN_EMAIL_RATE_LIMIT = int(os.environ.get("DEVPLACE_LOGIN_EMAIL_RATE_LIMIT", "10")) ``` - Add a new process‑local dictionary for email counters (alongside the existing `_rate_limit_store`): ```python _email_rate_limit_store: dict[str, list[float]] = defaultdict(list) ``` - Add a helper function to check and enforce the email‑based limit (or keep it in `login.py` for cohesion; decide to place it in `login.py` to avoid cross‑module coupling). **Actually, keep the store and helper in `main.py` because the window logic already exists there.** Add a function `check_email_rate_limit(email: str) -> bool` that returns `True` if the email is within limit, `False` if blocked. This function will also prune old timestamps and add the new timestamp. #### 2. `devplacepy/routers/auth/login.py` (the login handler) - Import `_email_rate_limit_store`, `LOGIN_EMAIL_RATE_LIMIT`, `RATE_WINDOW`, and the helper from `main.py` (or pass them through the application context; a simple `from devplacepy.main import ...` is acceptable given the current codebase structure). - After the failed password verification (after line 74 where the audit event `auth.login.failure` is recorded), call `check_email_rate_limit(email)`. If it returns `False` (blocked), return a 429 response with audit type `security.rate_limit.email_block` (following the pattern at `main.py:515`). Example: ```python if not check_email_rate_limit(email): audit( request=request, action="security.rate_limit.email_block", metadata={"email": email, "ip": client_ip(request)}, ) raise HTTPException(status_code=429, detail="Too many login attempts for this account") ``` #### 3. `tests/api/auth/login.py` - Add a new test `test_rate_limit_email_block_recorded`: - Patch `LOGIN_EMAIL_RATE_LIMIT` to `2` (via `monkeypatch.setenv` or directly on the module constant). - Ensure `DEVPLACE_DISABLE_RATE_LIMIT` is not set (or reset it to `0` for the test). - Send 3 failed login attempts with the **same email** (`test@example.com`), using different IPs (or same IP – the point is email tracking). - Assert that the first two attempts return 401, the third returns 429. - Assert that an audit event `security.rate_limit.email_block` is recorded. - Modify the existing `test_rate_limit_block_recorded` to ensure it still passes after the changes (it may need to use a low per‑IP limit but high per‑email limit – the existing test uses `limit=1`, which will still work because the email limit defaults to 10 and only 4 requests are sent; no conflict). #### 4. `tests/conftest.py` - Add a high default for the email limit to prevent accidental blocking during other tests: ```python os.environ.setdefault("DEVPLACE_LOGIN_EMAIL_RATE_LIMIT", "1000000") ``` - Optionally add a `DEVPLACE_DISABLE_LOGIN_EMAIL_RATE_LIMIT` flag but not necessary if default is high. ### No Changes Required - **`devplacepy/utils/guards.py`** – IP resolution remains unchanged. - **`tests/api/root.py`** – not affected. ### Verification - Syntax: `python3 -m py_compile devplacepy/main.py devplacepy/routers/auth/login.py tests/api/auth/login.py tests/conftest.py` must exit 0. - Lint: `ruff check .` must report zero new errors. - Unit tests: `make test-unit` must pass (including the new test). - No em‑dashes or non‑ASCII characters introduced. ### Definition of Done - [ ] `devplacepy/main.py` contains `LOGIN_EMAIL_RATE_LIMIT` constant and `_email_rate_limit_store` dictionary. - [ ] `devplacepy/main.py` exports a function `check_email_rate_limit(email: str) -> bool` that enforces a sliding‑window of `LOGIN_EMAIL_RATE_LIMIT` attempts per `RATE_WINDOW` seconds per email. - [ ] `devplacepy/routers/auth/login.py` imports and calls `check_email_rate_limit` after each failed login, returning 429 with audit `security.rate_limit.email_block` when blocked. - [ ] `tests/conftest.py` sets `DEVPLACE_LOGIN_EMAIL_RATE_LIMIT=1000000` to disable the email limit globally for other tests. - [ ] `tests/api/auth/login.py` contains a new test `test_rate_limit_email_block_recorded` that verifies email‑based rate limiting with patched limit=2. - [ ] All modified files pass `python3 -m py_compile`. - [ ] `ruff check .` reports no regressions. - [ ] `make test-unit` exits with code 0 and all tests pass. - [ ] No em‑dashes or other non‑ASCII characters appear in the changed files (outside existing strings).