4 Commits

Author SHA1 Message Date
dcee222a41 chore: release v0.9.19-rc.2 2026-05-22 20:38:33 +02:00
12fe70d768 chore: bump to v0.9.19-rc.1 and add local OpenAPI build support
- Add release candidate (rc) support to release.sh with latestRC tagging
- Bump VERSION, backend pyproject.toml, and frontend package.json to 0.9.19-rc.1
- Add local frontend/openapi.json so build no longer needs running backend
- Update generate:types and validate-types.sh to use local openapi.json
- Fix frontend tests: remove unused imports/variables and update mock data
2026-05-22 20:36:14 +02:00
83b2cb67b1 backup
Some checks failed
CI / Backend Tests (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Import Boundary (pull_request) Has been cancelled
CI / OpenAPI Breaking Changes (pull_request) Has been cancelled
CI / OpenAPI Baseline Commit (pull_request) Has been cancelled
2026-05-20 20:18:58 +02:00
7308ff88d6 fix(rate-limit): stop double-counting requests in middleware
Multiple RateLimitMiddleware instances were each calling
check_allowed() on every request, halving the effective global
limit (200 req/min became ~100). Added path_prefixes and skip_paths
so each instance only checks the paths it owns.

- Auth middleware scoped to /api/v1/auth/login and /api/v1/setup
- History middleware scoped to /api/v1/history
- Global middleware skips auth and history paths
- Updated tests to match single-count behavior
2026-05-15 23:04:02 +02:00
16 changed files with 10550 additions and 105 deletions

View File

@@ -18,7 +18,7 @@ WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* /build/
RUN npm ci --ignore-scripts
# Copy source and build
# Copy source + local OpenAPI spec (avoids needing a running backend during build)
COPY frontend/ /build/
RUN npm run build

View File

@@ -1 +1 @@
v0.9.19
v0.9.19-rc.2

View File

@@ -6,7 +6,7 @@
# ./release.sh
#
# The current version is stored in VERSION (next to this script).
# You will be asked whether to bump major, minor, or patch.
# You will be asked whether to bump major, minor, patch, or release candidate (rc).
set -euo pipefail
@@ -24,24 +24,60 @@ CURRENT="$(cat "${VERSION_FILE}")"
# Strip leading 'v' for arithmetic
VERSION="${CURRENT#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
# Parse version: X.Y.Z or X.Y.Z-rc.N
if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$ ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
RC_SUFFIX="${BASH_REMATCH[4]:-}"
RC_NUM="${BASH_REMATCH[5]:-0}"
else
echo "Error: version '${VERSION}' does not match expected format X.Y.Z or X.Y.Z-rc.N" >&2
exit 1
fi
echo "============================================"
echo " BanGUI — Release"
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
if [[ -n "${RC_SUFFIX}" ]]; then
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM}"
else
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
fi
echo "============================================"
echo ""
echo "How would you like to bump the version?"
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
if [[ -n "${RC_SUFFIX}" ]]; then
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH})"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v$((MAJOR + 1)).0.0)"
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1)))"
else
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.${PATCH}-rc.1)"
fi
echo ""
read -rp "Enter choice [1/2/3]: " CHOICE
read -rp "Enter choice [1/2/3/4]: " CHOICE
case "${CHOICE}" in
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
1)
if [[ -n "${RC_SUFFIX}" ]]; then
# Release the RC: strip RC suffix
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
else
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
;;
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
4)
if [[ "${RC_NUM}" -gt 0 ]]; then
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1))"
else
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.1"
fi
;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
@@ -81,7 +117,13 @@ fi
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# Push to "latest" or "latestRC" depending on whether this is a release candidate
if [[ "${NEW_TAG}" == *-rc* ]]; then
bash "${SCRIPT_DIR}/push.sh" "latestRC"
else
bash "${SCRIPT_DIR}/push.sh" "latest"
fi
# ---------------------------------------------------------------------------

View File

@@ -242,9 +242,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# deployments, it should be replaced with a shared backend.
_update_session_cache(app, settings)
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
# Initialize the global rate limiter (600 requests per 60 seconds per IP).
# Applied to all endpoints via middleware. Process-local implementation.
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=600, window_seconds=60)
log.info("bangui_started")
@@ -1095,10 +1095,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
else NoOpSessionCache()
)
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
# Initialize the global rate limiter (600 requests per 60 seconds per IP).
# This is also re-initialized in the lifespan, but must be present here
# for tests that bypass the lifespan via ASGITransport.
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=600, window_seconds=60)
set_setup_complete_cache(app, False)
@@ -1135,9 +1135,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_middleware(CsrfMiddleware)
app.add_middleware(DeprecationHeaderMiddleware)
# Auth endpoints (login, setup) need a dedicated higher-rate bucket to avoid
# rate limiting when running e2e tests sequentially. Auth uses the default
# global rate limiter at 200 req/min per IP.
# Auth endpoints: /api/v1/login, /api/v1/setup
# rate limiting when running e2e tests sequentially.
# 1000 req/min per IP — generous for e2e testing.
app.add_middleware(
RateLimitMiddleware,
@@ -1146,6 +1144,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
bucket_override="auth:login",
bucket_max_requests=1000,
bucket_window_seconds=60,
path_prefixes=["/api/v1/auth/login", "/api/v1/setup"],
)
# History endpoints get a dedicated higher-rate bucket to avoid
@@ -1159,6 +1158,28 @@ def create_app(settings: Settings | None = None) -> FastAPI:
bucket_override="history:list",
bucket_max_requests=10000,
bucket_window_seconds=60,
path_prefixes=["/api/v1/history"],
)
# Polling endpoints (blocklist schedule) get a dedicated bucket
# to avoid exhausting the global limit during normal frontend operation.
app.add_middleware(
RateLimitMiddleware,
rate_limiter=app.state.global_rate_limiter,
settings=resolved_settings,
bucket_override="polling:read",
bucket_max_requests=10000,
bucket_window_seconds=60,
path_prefixes=["/api/v1/blocklists/schedule"],
)
# Global rate limiter for all other endpoints.
# 600 req/min per IP — default protection.
app.add_middleware(
RateLimitMiddleware,
rate_limiter=app.state.global_rate_limiter,
settings=resolved_settings,
skip_paths=["/api/v1/auth/login", "/api/v1/setup", "/api/v1/history", "/api/v1/blocklists/schedule"],
)
# Validate middleware order before returning the app.

View File

@@ -34,18 +34,20 @@ unusual and potentially suspicious) always carry a correlation ID for tracing.
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
from app.utils.logging_compat import get_logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.exceptions import RateLimitError
from app.utils.client_ip import get_client_ip
from app.utils.logging_compat import get_logger
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from app.config import Settings
from app.utils.rate_limiter import GlobalRateLimiter
@@ -53,11 +55,15 @@ log = get_logger(__name__)
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Enforce global per-IP request rate limiting on all endpoints.
"""Enforce per-IP request rate limiting on matching endpoints.
Tracks requests per IP and blocks further requests if the limit is exceeded.
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
for consistent IP extraction.
Each middleware instance is scoped to a set of path prefixes (or all paths
if no prefixes are given). This allows multiple instances to coexist
without double-counting requests.
"""
def __init__(
@@ -68,6 +74,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
bucket_override: str | None = None,
bucket_max_requests: int | None = None,
bucket_window_seconds: int | None = None,
path_prefixes: list[str] | None = None,
skip_paths: list[str] | None = None,
) -> None:
"""Initialize the rate limit middleware.
@@ -78,6 +86,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
bucket_override: Optional named bucket to use instead of the default limiter.
bucket_max_requests: Max requests for the bucket override.
bucket_window_seconds: Window for the bucket override.
path_prefixes: If provided, only apply rate limiting to paths that
start with one of these prefixes. If ``None``, all paths are
matched.
skip_paths: If provided, do not apply rate limiting to paths that
start with one of these prefixes. Evaluated after
``path_prefixes``.
"""
super().__init__(app) # type: ignore[arg-type]
self.rate_limiter: GlobalRateLimiter = rate_limiter
@@ -85,6 +99,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.bucket_override = bucket_override
self.bucket_max_requests = bucket_max_requests
self.bucket_window_seconds = bucket_window_seconds
self.path_prefixes = path_prefixes or []
self.skip_paths = skip_paths or []
def _should_check(self, path: str) -> bool:
"""Return whether the given path should be rate-limited by this instance.
Args:
path: The request URL path.
Returns:
``True`` if this instance should enforce its limit on the path.
"""
if self.skip_paths and any(path.startswith(p) for p in self.skip_paths):
return False
if self.path_prefixes:
return any(path.startswith(p) for p in self.path_prefixes)
return True
async def dispatch(
self,
@@ -103,37 +134,28 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
Returns:
A response object (either rate limit response or from handler).
"""
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
# Use higher-rate bucket for specific endpoints.
# Check path to apply the appropriate bucket.
path = request.url.path
if not self._should_check(path):
return await call_next(request)
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.bucket_override and self.bucket_max_requests and self.bucket_window_seconds:
if path.startswith("/api/v1/history"):
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
elif path.startswith("/api/v1/login") or path.startswith("/api/v1/setup"):
# Auth endpoints use their own bucket
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
else:
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
else:
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
if not is_allowed:
log.warning(
"global_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
path=path,
method=request.method,
retry_after=retry_after,
)
@@ -141,7 +163,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"Too many requests. Please try again later.",
retry_after_seconds=retry_after,
)
# Return the error response directly
return JSONResponse(
status_code=429,
content={
@@ -153,6 +174,5 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
headers={"Retry-After": str(int(retry_after))},
)
# Request is allowed, continue to next handler
response: Response = await call_next(request)
return response

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bangui-backend"
version = "0.9.19"
version = "0.9.19-rc.1"
description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12"
dependencies = [

View File

@@ -134,24 +134,17 @@ class TestRateLimitMiddleware:
"""Global rate limit should block requests exceeding per-IP limit."""
await _do_setup(client)
# Create a client that mimics a specific IP
# We'll make many requests and see if we hit the limit
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# Reduce limit temporarily for testing.
# Each request is checked by two middleware instances, so the
# effective limit is doubled for non-bucket endpoints.
original_max = limiter.max_requests
limiter.max_requests = 7
limiter.max_requests = 3
try:
# First 3 requests should succeed
for i in range(3):
response = await client.get("/api/v1/health")
assert response.status_code == 200, f"Request {i + 1} failed"
# Fourth request should be rate limited
response = await client.get("/api/v1/health")
assert response.status_code == 429
assert response.json()["code"] == "rate_limit_exceeded"
@@ -166,22 +159,47 @@ class TestRateLimitMiddleware:
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# Two middleware instances check each request, so the effective
# limit is doubled for non-bucket endpoints.
original_max = limiter.max_requests
limiter.max_requests = 3
limiter.max_requests = 2
try:
# First request succeeds
response = await client.get("/api/v1/health")
assert response.status_code == 200
# Second request is rate limited
response = await client.get("/api/v1/health")
assert response.status_code == 200
response = await client.get("/api/v1/health")
assert response.status_code == 429
assert "Retry-After" in response.headers
retry_after = int(response.headers["Retry-After"])
assert retry_after > 0
assert retry_after <= 60 # Should be less than window
assert retry_after <= 60
finally:
limiter.max_requests = original_max
async def test_auth_bucket_allows_more_requests(self, client: AsyncClient) -> None:
"""Auth endpoints use a dedicated high-rate bucket."""
await _do_setup(client)
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# The auth bucket is configured for 1000 req/min; we only need to
# verify that it is *not* the global bucket (200 req/min).
for _ in range(5):
response = await client.post("/api/v1/auth/login", json={"password": "x"})
assert response.status_code in (401, 403, 429)
async def test_history_bucket_allows_more_requests(self, client: AsyncClient) -> None:
"""History endpoints use a dedicated high-rate bucket."""
await _do_setup(client)
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
for _ in range(5):
response = await client.get("/api/v1/history/bans")
# 401/403 is fine — we just need to confirm we are not 429'd
# by the global limiter.
assert response.status_code != 429

10343
frontend/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{
"name": "bangui-frontend",
"private": true,
"version": "0.9.19",
"version": "0.9.19-rc.2",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {
"dev": "vite",
"generate:types": "openapi-typescript http://localhost:8000/api/openapi.json -o src/types/generated.ts",
"generate:types": "openapi-typescript ./openapi.json -o src/types/generated.ts",
"validate:types": "bash scripts/validate-types.sh",
"build": "npm run generate:types && tsc --noEmit && vite build",
"preview": "vite preview",

View File

@@ -17,17 +17,23 @@ GENERATED_FILE="${TYPES_DIR}/generated.ts"
TEMP_FILE=$(mktemp)
trap "rm -f $TEMP_FILE" EXIT
# Check if backend is accessible
# Determine OpenAPI source: local file or backend URL
BACKEND_URL="${BANGUI_BACKEND_URL:-http://localhost:8000}"
if ! curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json" >&2
OPENAPI_SOURCE=""
if [[ -f "${FRONTEND_DIR}/openapi.json" ]]; then
OPENAPI_SOURCE="${FRONTEND_DIR}/openapi.json"
echo "📋 Validating OpenAPI schema types (local openapi.json)..."
elif curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
OPENAPI_SOURCE="${BACKEND_URL}/api/openapi.json"
echo "📋 Validating OpenAPI schema types (backend ${BACKEND_URL})..."
else
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json and no local openapi.json found" >&2
exit 2
fi
echo "📋 Validating OpenAPI schema types..."
# Generate types to a temporary file
if ! npx openapi-typescript "${BACKEND_URL}/api/openapi.json" -o "$TEMP_FILE" 2>&1; then
if ! npx openapi-typescript "${OPENAPI_SOURCE}" -o "$TEMP_FILE" 2>&1; then
echo "❌ Failed to generate types from OpenAPI schema" >&2
exit 3
fi

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "../ErrorBoundary";
import * as telemetry from "../../utils/telemetry";
// Mock telemetry to verify it's called
vi.mock("../../utils/telemetry");

View File

@@ -468,13 +468,10 @@ describe("useFetchData", () => {
});
it("last subscriber abort cancels underlying request", async () => {
let resolveFirst: ((value: { value: string }) => void) | null = null;
const abortSignals: AbortSignal[] = [];
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
abortSignals.push(signal);
return new Promise((resolve) => {
resolveFirst = resolve;
});
return new Promise(() => {});
});
const selector = vi.fn((response: { value: string }) => response.value);

View File

@@ -10,7 +10,7 @@ describe("useJailBannedIps", () => {
const fetchMock = vi.mocked(api.fetchJailBannedIps);
const unbanMock = vi.mocked(api.unbanIp);
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25 });
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25, total_pages: 1, pagination_mode: "offset" });
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true });
const { result } = renderHook(() => useJailBannedIps("sshd"));

View File

@@ -34,8 +34,6 @@ describe("usePolledData", () => {
vi.runAllTimersAsync();
});
const callCountAfterInitial = fetcher.mock.calls.length;
// Reset timer and advance to ensure no more polls
vi.clearAllTimers();
fetcher.mockClear();
@@ -66,8 +64,6 @@ describe("usePolledData", () => {
vi.advanceTimersByTime(100);
});
const initialCalls = fetcher.mock.calls.length;
// Clear for clean test
fetcher.mockClear();
@@ -135,7 +131,6 @@ describe("usePolledData", () => {
vi.advanceTimersByTime(100);
});
const initialCalls = fetcher.mock.calls.length;
fetcher.mockClear();
// Call refresh

View File

@@ -77,11 +77,34 @@ export function usePolledData<TResponse, TData>(
pauseWhenHidden = false,
} = options;
// Stabilize fetcher/selector/onSuccess references so that useFetchData's
// refresh callback (and the useEffect that calls it) don't re-trigger on
// every render when callers pass inline functions.
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const selectorRef = useRef(selector);
selectorRef.current = selector;
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
const stableFetcher = useCallback(
(signal: AbortSignal) => fetcherRef.current(signal),
[],
);
const stableSelector = useCallback(
(response: TResponse) => selectorRef.current(response),
[],
);
const stableOnSuccess = useCallback(
(response: TResponse) => onSuccessRef.current?.(response),
[],
);
const { data, loading, error, refresh } = useFetchData({
fetcher,
selector,
fetcher: stableFetcher,
selector: stableSelector,
errorMessage,
onSuccess,
onSuccess: onSuccessRef.current ? stableOnSuccess : undefined,
initialData,
});
@@ -151,15 +174,10 @@ export function usePolledData<TResponse, TData>(
return;
}
// Record when polling starts and schedule first poll immediately
// Record when polling starts. The initial fetch is handled by useFetchData's
// mount effect, so we just mark the start time and let the loading-completion
// effect (above) schedule the first poll after the initial fetch finishes.
pollStartTimeRef.current = performance.now();
const id = window.setTimeout((): void => {
if (cancelledRef.current) return;
pollStartTimeRef.current = performance.now();
refreshRef.current?.();
}, 0);
timeoutIdRef.current = id;
return (): void => {
cancelledRef.current = true;

View File

@@ -177,11 +177,6 @@ export interface paths {
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
* cookie so the browser SPA benefits from automatic credential handling.
*
* Rate limiting: Exponential backoff on failed attempts. Each wrong password
* incurs an increasing delay (0.5s, 1s, 2s, 4s, 5s max per IP address).
* Requests during the penalty period return ``429 Too Many Requests`` with
* a ``Retry-After`` header.
*
* Cache invalidation: On successful login, any existing cached sessions for
* the same user are invalidated so that stale tokens (e.g., from a stolen
* device) cannot be reused beyond the cache TTL window.
@@ -192,7 +187,6 @@ export interface paths {
* request: The incoming HTTP request (used to extract client IP).
* session_ctx: Session service context containing db and repository.
* settings: Application settings (used for session duration and trusted proxies).
* rate_limiter: The login rate limiter (per IP).
* session_cache: Session cache for invalidating old sessions on login.
*
* Returns:
@@ -200,7 +194,6 @@ export interface paths {
*
* Raises:
* AuthenticationError: if the password is incorrect.
* RateLimitError: if the rate limit is exceeded.
*/
post: operations["login_api_v1_auth_login_post"];
delete?: never;
@@ -6274,13 +6267,6 @@ export interface operations {
};
content?: never;
};
/** @description Too many login attempts, retry after delay */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Setup not complete */
503: {
headers: {