Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/backend/app/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions src/backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 28 additions & 12 deletions src/backend/tests/integration/test_auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
12 changes: 6 additions & 6 deletions src/frontend/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading