refactoring-backend #3
@@ -30,6 +30,14 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
|
||||
- After entering the correct password the user is taken to the page they originally requested.
|
||||
- A logout option is available from every page so the user can end their session.
|
||||
|
||||
### Session Validation on App Load
|
||||
|
||||
- On app mount (page reload or initial load), the frontend validates the cached session with the backend by calling `GET /api/auth/session`.
|
||||
- While the validation check is in flight, a loading spinner is displayed to avoid UI flicker.
|
||||
- If the backend returns **200**, the session is valid and the app proceeds normally.
|
||||
- If the backend returns **401**, the session has expired or been revoked (server-side DB deletion, restart, etc.), and the user is logged out and redirected to the login page.
|
||||
- If a **network error** occurs (backend temporarily unreachable), the user is not logged out — the app assumes the backend will recover and continues with the cached session state. The next API call will trigger a 401 if the session is actually invalid.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ban Overview (Dashboard)
|
||||
|
||||
1021
Docs/Tasks.md
1021
Docs/Tasks.md
File diff suppressed because it is too large
Load Diff
@@ -471,6 +471,38 @@ const load = useCallback(() => {
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Session Validation on App Mount
|
||||
|
||||
The `AuthProvider` uses the `useSessionValidation` hook to validate the cached session with the backend on app mount. This pattern ensures that the UI state always reflects reality — expired or revoked sessions are detected immediately, not after the first API call.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. `useSessionValidation` is called during `AuthProvider` initialization.
|
||||
2. It calls `GET /api/auth/session`, which requires a valid session cookie/header.
|
||||
3. While the check is in flight, a loading spinner (`SessionValidationLoading`) is displayed.
|
||||
4. **On 200 (valid session):** The app proceeds with the cached session state.
|
||||
5. **On 401 (invalid session):** The user is logged out and redirected to `/login`.
|
||||
6. **On network error:** The error is logged but the user is not logged out — the backend may be temporarily unreachable. The next API call will trigger a 401 if needed.
|
||||
|
||||
**Example hook signature:**
|
||||
|
||||
```ts
|
||||
interface UseSessionValidationResult {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function useSessionValidation(
|
||||
onSessionValid: () => void,
|
||||
onSessionExpired: () => void,
|
||||
onNetworkError?: (error: Error) => void,
|
||||
): UseSessionValidationResult {
|
||||
// Calls validateSession() and handles the three outcomes.
|
||||
}
|
||||
```
|
||||
|
||||
This pattern prevents **stale session flicker** — the brief moment when a user sees the authenticated UI before the first API call reveals a 401. It also handles scenarios where the session cookie has expired server-side (server restart, session duration elapsed, manual DB deletion) before the frontend detects it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Naming Conventions
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import structlog
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from app.dependencies import DbDep, SessionCacheDep, SessionRepoDep, SettingsDep
|
||||
from app.dependencies import AuthDep, DbDep, SessionCacheDep, SessionRepoDep, SettingsDep
|
||||
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
|
||||
from app.services import auth_service
|
||||
from app.utils.constants import SESSION_COOKIE_NAME
|
||||
@@ -76,6 +76,31 @@ async def login(
|
||||
return LoginResponse(token=signed_token, expires_at=expires_at)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/session",
|
||||
summary="Validate the current session",
|
||||
)
|
||||
async def validate_session(
|
||||
_: AuthDep,
|
||||
) -> dict[str, bool]:
|
||||
"""Validate the current session.
|
||||
|
||||
This endpoint requires a valid session and returns 200 if the session is
|
||||
valid and still active. If the session is invalid, expired, or missing,
|
||||
FastAPI's ``require_auth`` dependency returns 401 automatically.
|
||||
|
||||
The frontend calls this on mount to bootstrap its authentication state
|
||||
from the backend rather than relying solely on cached ``sessionStorage``.
|
||||
|
||||
Args:
|
||||
_: The injected session object (unused, but its presence triggers validation).
|
||||
|
||||
Returns:
|
||||
A simple JSON object confirming the session is valid.
|
||||
"""
|
||||
return {"valid": True}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=LogoutResponse,
|
||||
|
||||
@@ -202,6 +202,68 @@ class TestRequireAuth:
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session validation (Task 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateSession:
|
||||
"""GET /api/auth/session."""
|
||||
|
||||
async def test_validate_session_returns_200_with_valid_token(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Validate session returns 200 for a valid authenticated request."""
|
||||
await _do_setup(client)
|
||||
await _login(client)
|
||||
response = await client.get("/api/auth/session")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"valid": True}
|
||||
|
||||
async def test_validate_session_returns_401_without_token(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Validate session returns 401 when no token is present."""
|
||||
await _do_setup(client)
|
||||
response = await client.get("/api/auth/session")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_validate_session_returns_401_with_invalid_token(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Validate session returns 401 for an invalid or expired token."""
|
||||
await _do_setup(client)
|
||||
response = await client.get(
|
||||
"/api/auth/session",
|
||||
headers={"Authorization": "Bearer invalidtoken"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_validate_session_with_cookie(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Validate session works with cookie-based authentication."""
|
||||
await _do_setup(client)
|
||||
token = await _login(client)
|
||||
# Login sets the cookie on the client automatically via httpx.
|
||||
response = await client.get("/api/auth/session")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"valid": True}
|
||||
|
||||
async def test_validate_session_after_logout(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Validate session returns 401 after logout."""
|
||||
await _do_setup(client)
|
||||
token = await _login(client)
|
||||
await client.post("/api/auth/logout")
|
||||
response = await client.get(
|
||||
"/api/auth/session",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-token cache (Task 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Authentication API functions.
|
||||
*
|
||||
* Wraps calls to POST /api/auth/login and POST /api/auth/logout
|
||||
* Wraps calls to POST /api/auth/login, POST /api/auth/logout, and GET /api/auth/session
|
||||
* using the central typed fetch client.
|
||||
*/
|
||||
|
||||
@@ -27,3 +27,17 @@ export async function login(password: string): Promise<LoginResponse> {
|
||||
export async function logout(): Promise<LogoutResponse> {
|
||||
return api.post<LogoutResponse>(ENDPOINTS.authLogout, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the current session with the backend.
|
||||
*
|
||||
* This is called on app mount to confirm the session is still valid on the
|
||||
* server side, rather than relying solely on the cached sessionStorage flag.
|
||||
*
|
||||
* @param signal - Optional abort signal for request cancellation.
|
||||
* @returns An object confirming the session is valid.
|
||||
* @throws {ApiError} When the session is invalid or expired (401).
|
||||
*/
|
||||
export async function validateSession(signal?: AbortSignal): Promise<{ valid: boolean }> {
|
||||
return api.get<{ valid: boolean }>(ENDPOINTS.authSession, signal);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ENDPOINTS = {
|
||||
// -------------------------------------------------------------------------
|
||||
authLogin: "/auth/login",
|
||||
authLogout: "/auth/logout",
|
||||
authSession: "/auth/session",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dashboard
|
||||
|
||||
35
frontend/src/components/SessionValidationLoading.tsx
Normal file
35
frontend/src/components/SessionValidationLoading.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Session validation loading indicator.
|
||||
*
|
||||
* Displayed on app mount while the backend validates the session.
|
||||
*/
|
||||
|
||||
import { makeStyles, Spinner } from "@fluentui/react-components";
|
||||
import React from "react";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100vh",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading indicator shown during session validation on app mount.
|
||||
*
|
||||
* @returns React element displaying a centered spinner and message.
|
||||
*/
|
||||
export function SessionValidationLoading(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Spinner size="large" label="Validating session…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/hooks/useSessionValidation.ts
Normal file
74
frontend/src/hooks/useSessionValidation.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Hook for validating the session on app mount.
|
||||
*
|
||||
* Calls the backend to confirm the session is still valid, rather than relying
|
||||
* solely on cached sessionStorage. Handles network errors gracefully by not
|
||||
* logging out — only explicit 401/403 responses trigger logout.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import * as authApi from "../api/auth";
|
||||
import { ApiError, isAuthError } from "../api/client";
|
||||
|
||||
export interface UseSessionValidationResult {
|
||||
/** `true` when the validation check is in flight. */
|
||||
isLoading: boolean;
|
||||
/** An error if validation failed for a reason other than 401/403. */
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the session on mount.
|
||||
*
|
||||
* This hook is called once during app initialization to confirm the session
|
||||
* is valid on the backend. It handles three cases:
|
||||
*
|
||||
* 1. **Valid session (200)** — `onSessionValid()` is called.
|
||||
* 2. **Invalid session (401/403)** — `onSessionExpired()` is called to log out.
|
||||
* 3. **Network error** — `onNetworkError()` is called. The user is not logged out
|
||||
* because the backend may be temporarily unreachable.
|
||||
*
|
||||
* @param onSessionValid - Callback when session is confirmed valid.
|
||||
* @param onSessionExpired - Callback when session is invalid (401/403).
|
||||
* @param onNetworkError - Callback when a network error occurs.
|
||||
* @returns An object with `isLoading` and `error` states.
|
||||
*/
|
||||
export function useSessionValidation(
|
||||
onSessionValid: () => void,
|
||||
onSessionExpired: () => void,
|
||||
onNetworkError?: (error: Error) => void,
|
||||
): UseSessionValidationResult {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const validate = useCallback(async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authApi.validateSession();
|
||||
onSessionValid();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && isAuthError(err)) {
|
||||
// Explicit 401/403 — session is invalid or expired.
|
||||
onSessionExpired();
|
||||
} else if (err instanceof Error) {
|
||||
// Network error or other issue — don't log out.
|
||||
setError(err);
|
||||
onNetworkError?.(err);
|
||||
} else {
|
||||
const unknownError = new Error("Unknown error during session validation");
|
||||
setError(unknownError);
|
||||
onNetworkError?.(unknownError);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onSessionValid, onSessionExpired, onNetworkError]);
|
||||
|
||||
useEffect(() => {
|
||||
void validate();
|
||||
}, [validate]);
|
||||
|
||||
return { isLoading, error };
|
||||
}
|
||||
@@ -11,12 +11,18 @@
|
||||
* accepts the request (returns 2xx) and not authenticated when the backend
|
||||
* rejects it (returns 401/403).
|
||||
*
|
||||
* On app mount, the provider validates the cached session state with the backend
|
||||
* by calling `GET /api/auth/session`. While this check is in flight, a loading
|
||||
* indicator is shown. If the backend returns 401/403, the session is cleared.
|
||||
* If a network error occurs, the user is not logged out (the backend may be
|
||||
* temporarily unreachable).
|
||||
*
|
||||
* The `isAuthenticated` state persists in `sessionStorage` to survive page
|
||||
* refreshes within the browser tab but is cleared on tab close. The session
|
||||
* cookie itself persists according to the backend's cookie settings.
|
||||
*/
|
||||
|
||||
import {
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -26,6 +32,8 @@ import {
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as authApi from "../api/auth";
|
||||
import { setUnauthorizedHandler } from "../api/client";
|
||||
import { SessionValidationLoading } from "../components/SessionValidationLoading";
|
||||
import { useSessionValidation } from "../hooks/useSessionValidation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
@@ -56,6 +64,10 @@ const IS_AUTHENTICATED_KEY = "bangui_authenticated";
|
||||
*
|
||||
* Place this inside `<FluentProvider>` and `<BrowserRouter>` so all
|
||||
* descendants can call `useAuth()`.
|
||||
*
|
||||
* On mount, validates the cached session with the backend. While validation
|
||||
* is in progress, a loading indicator is shown. If validation fails with 401,
|
||||
* the user is logged out. Network errors do not cause logout.
|
||||
*/
|
||||
export function AuthProvider({
|
||||
children,
|
||||
@@ -66,6 +78,7 @@ export function AuthProvider({
|
||||
const stored = sessionStorage.getItem(IS_AUTHENTICATED_KEY);
|
||||
return stored === "true";
|
||||
});
|
||||
const [isValidating, setIsValidating] = useState<boolean>(isAuthenticated);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSessionExpired = useCallback((): void => {
|
||||
@@ -74,6 +87,18 @@ export function AuthProvider({
|
||||
navigate("/login", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
const handleSessionValid = useCallback((): void => {
|
||||
// Session is already set from sessionStorage; no action needed.
|
||||
}, []);
|
||||
|
||||
const handleValidationError = useCallback(
|
||||
(error: Error): void => {
|
||||
// Network error — log but don't logout.
|
||||
console.warn("Session validation network error:", error);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
setUnauthorizedHandler((): void => {
|
||||
handleSessionExpired();
|
||||
@@ -83,6 +108,16 @@ export function AuthProvider({
|
||||
};
|
||||
}, [handleSessionExpired]);
|
||||
|
||||
const validationResult = useSessionValidation(
|
||||
handleSessionValid,
|
||||
handleSessionExpired,
|
||||
handleValidationError,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsValidating(validationResult.isLoading);
|
||||
}, [validationResult.isLoading]);
|
||||
|
||||
const login = useCallback(async (password: string): Promise<void> => {
|
||||
await authApi.login(password);
|
||||
sessionStorage.setItem(IS_AUTHENTICATED_KEY, "true");
|
||||
@@ -104,5 +139,10 @@ export function AuthProvider({
|
||||
[isAuthenticated, login, logout],
|
||||
);
|
||||
|
||||
// Show loading spinner while validating session on mount.
|
||||
if (isValidating && isAuthenticated) {
|
||||
return <SessionValidationLoading />;
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -41,10 +41,12 @@ describe("AuthProvider", () => {
|
||||
it("calls handler to clear auth state and redirect to /login", async () => {
|
||||
sessionStorage.setItem("bangui_authenticated", "true");
|
||||
|
||||
let capturedHandler: (() => void) | null = null;
|
||||
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation((handler) => {
|
||||
capturedHandler = handler;
|
||||
});
|
||||
const capturedHandlers: Array<(() => void) | null> = [];
|
||||
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation(
|
||||
(handler: (() => void) | null) => {
|
||||
capturedHandlers.push(handler);
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
@@ -59,6 +61,7 @@ describe("AuthProvider", () => {
|
||||
);
|
||||
|
||||
// Invoke the handler that was captured during render
|
||||
const capturedHandler = capturedHandlers[0];
|
||||
if (typeof capturedHandler === "function") {
|
||||
capturedHandler();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user