fix: setup routing, async bcrypt, password hashing, clean command
- Add SetupGuard component: redirects to /setup if setup not complete, shown as spinner while loading. All routes except /setup now wrapped. - SetupPage redirects to /login on mount when setup already done. - Fix async blocking: offload bcrypt.hashpw and bcrypt.checkpw to run_in_executor so they never stall the asyncio event loop. - Hash password with SHA-256 (SubtleCrypto) before transmission; added src/utils/crypto.ts with sha256Hex(). Backend stores bcrypt(sha256). - Add Makefile with make up/down/restart/logs/clean targets. - Add tests: _check_password async, concurrent bcrypt, expired session, login-without-setup, run_setup event-loop interleaving. - Update Architekture.md and Features.md to reflect all changes.
This commit is contained in:
@@ -7,8 +7,8 @@
|
||||
* 3. `AuthProvider` — manages session state and exposes `useAuth()`.
|
||||
*
|
||||
* Routes:
|
||||
* - `/setup` — first-run setup wizard (always accessible)
|
||||
* - `/login` — master password login
|
||||
* - `/setup` — first-run setup wizard (always accessible; redirects to /login if already done)
|
||||
* - `/login` — master password login (redirects to /setup if not done)
|
||||
* - `/` — dashboard (protected, inside MainLayout)
|
||||
* - `/map` — world map (protected)
|
||||
* - `/jails` — jail list (protected)
|
||||
@@ -25,6 +25,7 @@ import { lightTheme } from "./theme/customTheme";
|
||||
import { AuthProvider } from "./providers/AuthProvider";
|
||||
import { TimezoneProvider } from "./providers/TimezoneProvider";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { SetupGuard } from "./components/SetupGuard";
|
||||
import { MainLayout } from "./layouts/MainLayout";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
@@ -45,18 +46,29 @@ function App(): React.JSX.Element {
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
{/* Setup wizard — always accessible; redirects to /login if already done */}
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes — all rendered inside MainLayout */}
|
||||
{/* Login — requires setup to be complete */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<SetupGuard>
|
||||
<LoginPage />
|
||||
</SetupGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes — require setup AND authentication */}
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<TimezoneProvider>
|
||||
<MainLayout />
|
||||
</TimezoneProvider>
|
||||
</RequireAuth>
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
<TimezoneProvider>
|
||||
<MainLayout />
|
||||
</TimezoneProvider>
|
||||
</RequireAuth>
|
||||
</SetupGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
|
||||
@@ -8,15 +8,20 @@
|
||||
import { api } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
|
||||
/**
|
||||
* Authenticate with the master password.
|
||||
*
|
||||
* The password is SHA-256 hashed client-side before transmission so that
|
||||
* the plaintext never leaves the browser. The backend bcrypt-verifies the
|
||||
* received hash against the stored bcrypt(sha256) digest.
|
||||
*
|
||||
* @param password - The master password entered by the user.
|
||||
* @returns The login response containing the session token.
|
||||
*/
|
||||
export async function login(password: string): Promise<LoginResponse> {
|
||||
const body: LoginRequest = { password };
|
||||
const body: LoginRequest = { password: await sha256Hex(password) };
|
||||
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
|
||||
}
|
||||
|
||||
|
||||
65
frontend/src/components/SetupGuard.tsx
Normal file
65
frontend/src/components/SetupGuard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Route guard component.
|
||||
*
|
||||
* Protects all routes by ensuring the initial setup wizard has been
|
||||
* completed. If setup is not done yet, the user is redirected to `/setup`.
|
||||
* While the status is loading a full-screen spinner is shown.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Spinner } from "@fluentui/react-components";
|
||||
import { getSetupStatus } from "../api/setup";
|
||||
|
||||
type Status = "loading" | "done" | "pending";
|
||||
|
||||
interface SetupGuardProps {
|
||||
/** The protected content to render when setup is complete. */
|
||||
children: React.JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `children` only when setup has been completed.
|
||||
*
|
||||
* Redirects to `/setup` if setup is still pending.
|
||||
*/
|
||||
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getSetupStatus()
|
||||
.then((res): void => {
|
||||
if (!cancelled) setStatus(res.completed ? "done" : "pending");
|
||||
})
|
||||
.catch((): void => {
|
||||
// If the check fails, optimistically allow through — the backend will
|
||||
// redirect API calls to /api/setup anyway.
|
||||
if (!cancelled) setStatus("done");
|
||||
});
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" label="Loading…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* All fields use Fluent UI v9 components and inline validation.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { submitSetup } from "../api/setup";
|
||||
import { getSetupStatus, submitSetup } from "../api/setup";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -105,6 +106,17 @@ export function SetupPage(): React.JSX.Element {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Redirect to /login if setup has already been completed.
|
||||
useEffect(() => {
|
||||
getSetupStatus()
|
||||
.then((res) => {
|
||||
if (res.completed) navigate("/login", { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore — stay on setup page */
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -149,8 +161,11 @@ export function SetupPage(): React.JSX.Element {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Hash the password client-side before transmission — the plaintext
|
||||
// never leaves the browser. The backend bcrypt-hashes the received hash.
|
||||
const hashedPassword = await sha256Hex(values.masterPassword);
|
||||
await submitSetup({
|
||||
master_password: values.masterPassword,
|
||||
master_password: hashedPassword,
|
||||
database_path: values.databasePath,
|
||||
fail2ban_socket: values.fail2banSocket,
|
||||
timezone: values.timezone,
|
||||
|
||||
23
frontend/src/utils/crypto.ts
Normal file
23
frontend/src/utils/crypto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Client-side cryptography utilities.
|
||||
*
|
||||
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return the SHA-256 hex digest of `input`.
|
||||
*
|
||||
* Hashing passwords before transmission means the plaintext never leaves the
|
||||
* browser, even when HTTPS is not enforced in a development environment.
|
||||
* The backend then applies bcrypt on top of the received hash.
|
||||
*
|
||||
* @param input - The string to hash (e.g. the master password).
|
||||
* @returns Lowercase hex-encoded SHA-256 digest.
|
||||
*/
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user