diff --git a/src/backend/app/rate_limit.py b/src/backend/app/rate_limit.py index d4bd1db..b4bbacc 100644 --- a/src/backend/app/rate_limit.py +++ b/src/backend/app/rate_limit.py @@ -58,5 +58,15 @@ def rate_limit_exceeded_handler( #: app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) limiter = Limiter(key_func=client_ip) -#: Brute-force limit applied to auth endpoints that accept credentials. +#: Brute-force limit applied to auth endpoints that accept credentials +#: (login). Deliberately strict because each attempt is a password guess. AUTH_RATE_LIMIT = "5/minute" + +#: Limit applied to the token-refresh endpoint. Refresh carries no +#: user-supplied credentials — only a signed, HttpOnly refresh cookie — and the +#: frontend calls it automatically on every page load (twice under React +#: StrictMode in dev). The strict login limit would therefore reject normal +#: reloads with a 429 and bounce the user to the login page, so refresh gets a +#: much more generous ceiling that still bounds abuse of the rotating-token +#: endpoint. +REFRESH_RATE_LIMIT = "60/minute" diff --git a/src/backend/app/routers/auth.py b/src/backend/app/routers/auth.py index d7b9e91..f1d8a4f 100644 --- a/src/backend/app/routers/auth.py +++ b/src/backend/app/routers/auth.py @@ -8,7 +8,7 @@ from app.database import get_db from app.dependencies import get_current_user from app.models.user import User -from app.rate_limit import AUTH_RATE_LIMIT, limiter +from app.rate_limit import AUTH_RATE_LIMIT, REFRESH_RATE_LIMIT, limiter from app.schemas.auth import AccessTokenResponse, LoginRequest from app.schemas.user import UserResponse from app.services import auth_service @@ -89,7 +89,7 @@ def login( @router.post("/refresh", response_model=AccessTokenResponse) -@limiter.limit(AUTH_RATE_LIMIT) +@limiter.limit(REFRESH_RATE_LIMIT) def refresh( request: Request, response: Response, diff --git a/src/backend/tests/integration/test_auth_router.py b/src/backend/tests/integration/test_auth_router.py index 8d2827e..a82528e 100644 --- a/src/backend/tests/integration/test_auth_router.py +++ b/src/backend/tests/integration/test_auth_router.py @@ -4,6 +4,7 @@ from app.config import settings from app.models.user import User +from app.rate_limit import REFRESH_RATE_LIMIT from tests.conftest import ADMIN_PASSWORD, INACTIVE_PASSWORD, VIEWER_PASSWORD @@ -270,23 +271,38 @@ def test_login_rate_limited_after_five_attempts( assert "detail" in sixth.json() -def test_refresh_rate_limited_after_five_attempts(client: TestClient) -> None: - """Six rapid refresh attempts from the same IP hit 429 on the sixth. +def test_refresh_not_rate_limited_at_login_threshold(client: TestClient) -> None: + """Refresh tolerates more requests than the strict login limit. - The refresh endpoint is called without a valid cookie so it would return - 401 anyway, but the rate limit is evaluated before authentication and - counts all requests regardless of cookie validity. + The frontend calls ``/auth/refresh`` automatically on every page load (twice + under React StrictMode in dev), so it must not share login's 5/minute + brute-force ceiling. Ten rapid refreshes — well past the login limit — must + never be rate-limited, otherwise normal reloads would bounce the user to the + login page with a 429. """ - for i in range(5): + for i in range(10): resp = client.post("/auth/refresh") - assert resp.status_code == 401, ( - f"attempt {i + 1}: expected 401, got {resp.status_code}" + assert resp.status_code != 429, ( + f"attempt {i + 1} unexpectedly rate-limited" ) - sixth = client.post("/auth/refresh") - assert sixth.status_code == 429 - assert "retry-after" in sixth.headers - assert "detail" in sixth.json() + +def test_refresh_eventually_rate_limited(client: TestClient) -> None: + """Refresh is still bounded — exceeding its own limit returns 429. + + The endpoint rotates the refresh token on each successful call, so it keeps + a generous-but-finite ceiling. Sending one request beyond REFRESH_RATE_LIMIT + from the same IP trips the limiter. + """ + limit = int(REFRESH_RATE_LIMIT.split("/")[0]) + + for _ in range(limit): + client.post("/auth/refresh") + + over_limit = client.post("/auth/refresh") + assert over_limit.status_code == 429 + assert "retry-after" in over_limit.headers + assert "detail" in over_limit.json() def test_login_under_limit_is_not_rate_limited( diff --git a/src/frontend/src/components/ui/badge.tsx b/src/frontend/src/components/ui/badge.tsx index 981f826..1e19eae 100644 --- a/src/frontend/src/components/ui/badge.tsx +++ b/src/frontend/src/components/ui/badge.tsx @@ -8,12 +8,12 @@ const badgeVariants = cva( { variants: { variant: { - default: "border-primary/30 bg-primary/10 text-foreground", - secondary: "border-border bg-secondary text-foreground", - success: "border-success/30 bg-success/10 text-foreground", - warning: "border-warning/30 bg-warning/10 text-foreground", - destructive: "border-destructive/30 bg-destructive/10 text-foreground", - outline: "border-border text-foreground", + default: "border-primary/30 bg-primary/10 text-primary", + secondary: "border-border bg-secondary text-secondary-foreground", + success: "border-success/30 bg-success/10 text-success", + warning: "border-warning/30 bg-warning/10 text-warning", + destructive: "border-destructive/30 bg-destructive/10 text-destructive", + outline: "border-border text-muted-foreground", }, }, defaultVariants: {