fix(api): все роутеры за авторизацией, отказ от слабого JWT_SECRET, лимитер и X-Forwarded-For
get_current_user навешивается на роутер целиком, публичными остаются только health и auth (список закреплён tests/api/test_route_auth.py). API не стартует с плейсхолдером или коротким JWT_SECRET без ALLOW_DEV_SECRET=1. Лимитер логинов ограничен по числу ключей. Клиентский IP берётся из последнего хопа X-Forwarded-For.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"""Every route answers 401 without a token, except a short, explicit list of public ones."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from fintracker.api.app import API_PREFIX
|
||||
|
||||
PUBLIC = {
|
||||
("GET", f"{API_PREFIX}/health"),
|
||||
("POST", f"{API_PREFIX}/auth/login"),
|
||||
("POST", f"{API_PREFIX}/auth/refresh"),
|
||||
("POST", f"{API_PREFIX}/auth/logout"),
|
||||
}
|
||||
|
||||
|
||||
def _routes(app) -> list[tuple[str, str]]:
|
||||
"""(METHOD, path) for every documented operation. Walks the OpenAPI schema rather than
|
||||
`app.routes`: FastAPI nests included routers there in a private wrapper type."""
|
||||
return sorted(
|
||||
(method.upper(), path) for path, item in app.openapi()["paths"].items() for method in item
|
||||
)
|
||||
|
||||
|
||||
async def test_every_route_but_the_public_ones_requires_a_token(app, client):
|
||||
routes = _routes(app)
|
||||
assert len(routes) > 50 # guards against the walk silently matching nothing
|
||||
assert set(routes) >= PUBLIC # a renamed public route must update this list, not vanish
|
||||
|
||||
open_routes = []
|
||||
for method, path in routes:
|
||||
if (method, path) in PUBLIC:
|
||||
continue
|
||||
url = re.sub(r"\{[^}]+\}", "1", path)
|
||||
r = await client.request(method, url)
|
||||
if r.status_code != 401:
|
||||
open_routes.append(f"{method} {path} -> {r.status_code}")
|
||||
assert not open_routes, "reachable without a token:\n" + "\n".join(open_routes)
|
||||
@@ -1,3 +1,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
async def test_login_refresh_logout_cycle(client, user):
|
||||
r = await client.post("/api/v1/auth/login", json=user)
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -41,3 +44,41 @@ async def test_protected_routes_need_token(client):
|
||||
assert r.status_code == 401
|
||||
r = await client.get("/api/v1/auth/me", headers={"Authorization": "Bearer garbage"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_client_ip_trusts_only_the_last_forwarded_hop():
|
||||
from starlette.requests import Request
|
||||
|
||||
from fintracker.api.deps import client_ip
|
||||
|
||||
def req(xff: str) -> Request:
|
||||
return Request({"type": "http", "headers": [(b"x-forwarded-for", xff.encode())]})
|
||||
|
||||
assert client_ip(req("1.2.3.4")) == "1.2.3.4"
|
||||
assert client_ip(req("6.6.6.6, 1.2.3.4")) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_limiter_evicts_instead_of_growing_without_bound():
|
||||
from fintracker.api.ratelimit import SlidingWindowLimiter
|
||||
|
||||
limiter = SlidingWindowLimiter(limit=1, max_keys=3)
|
||||
for i in range(10):
|
||||
assert limiter.hit(f"ip-{i}") is None
|
||||
assert len(limiter._hits) <= 3
|
||||
assert limiter.hit("ip-9") is not None # the freshest key keeps its state
|
||||
|
||||
|
||||
async def test_weak_jwt_secret_refuses_to_start(monkeypatch):
|
||||
from fintracker.api.app import _lifespan
|
||||
from fintracker.config import get_settings
|
||||
|
||||
monkeypatch.setenv("JWT_SECRET", "change-me")
|
||||
monkeypatch.delenv("ALLOW_DEV_SECRET", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="JWT_SECRET"):
|
||||
async with _lifespan(None): # type: ignore[arg-type]
|
||||
pass
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
|
||||
Reference in New Issue
Block a user