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:
2026-03-01 19:16:49 +01:00
parent 1cdc97a729
commit c097e55222
13 changed files with 347 additions and 394 deletions

View File

@@ -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 />} />

View File

@@ -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);
}

View 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;
}

View File

@@ -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,

View 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("");
}