feat: implement basic authentication system

This commit is contained in:
Adrian Acala
2026-01-16 21:34:58 -08:00
parent d1dbde95e4
commit 20ef4ee295
26 changed files with 975 additions and 23 deletions
+21
View File
@@ -154,6 +154,27 @@ backend:
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
### Optional Authentication
ExcaliDash can enforce a single username/password to protect the dashboard and API.
Set these backend environment variables to enable it:
```bash
AUTH_USERNAME=admin
AUTH_PASSWORD=change-me
# Recommended: keep sessions stable across restarts
AUTH_SESSION_SECRET=your-random-secret
# Optional (default: 168 hours)
AUTH_SESSION_TTL_HOURS=168
# Optional (default: excalidash_auth)
AUTH_COOKIE_NAME=excalidash_auth
# Optional: lax | strict | none (use "none" for cross-site hosting)
AUTH_COOKIE_SAMESITE=lax
```
When enabled, the UI prompts for a login before accessing any drawings,
and all API/WebSocket traffic requires the session cookie.
# Development
## Clone the Repository
+2
View File
@@ -3,3 +3,5 @@ PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
# Optional auth cookie settings: lax | strict | none
AUTH_COOKIE_SAMESITE=lax
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
import {
buildAuthConfig,
createAuthSessionToken,
getAuthSessionFromCookie,
validateAuthSessionToken,
verifyCredentials,
} from "../auth";
describe("Auth utilities", () => {
it("disables auth when credentials are missing", () => {
const config = buildAuthConfig({});
expect(config.enabled).toBe(false);
});
it("verifies credentials and validates issued session tokens", () => {
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "super-secret",
AUTH_SESSION_SECRET: "test-secret",
});
expect(verifyCredentials(config, "admin", "super-secret")).toBe(true);
expect(verifyCredentials(config, "admin", "wrong")).toBe(false);
const token = createAuthSessionToken(config, "admin");
const session = validateAuthSessionToken(config, token);
expect(session).not.toBeNull();
expect(session?.username).toBe("admin");
});
it("rejects expired session tokens", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret",
AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds
});
const token = createAuthSessionToken(config, "admin");
vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
expect(validateAuthSessionToken(config, token)).toBeNull();
vi.useRealTimers();
});
it("extracts session tokens from cookies", () => {
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret",
});
const token = createAuthSessionToken(config, "admin");
const cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`;
const session = getAuthSessionFromCookie(cookieHeader, config);
expect(session?.username).toBe("admin");
});
});
+215
View File
@@ -0,0 +1,215 @@
import crypto from "crypto";
export type AuthSameSite = "lax" | "strict" | "none";
export type AuthConfig = {
enabled: boolean;
username: string;
password: string;
sessionTtlMs: number;
cookieName: string;
cookieSameSite: AuthSameSite;
secret: Buffer;
};
export type AuthSession = {
username: string;
iat: number;
exp: number;
};
const DEFAULT_SESSION_TTL_HOURS = 24 * 7;
const DEFAULT_COOKIE_NAME = "excalidash_auth";
const DEFAULT_COOKIE_SAMESITE: AuthSameSite = "lax";
const base64UrlEncode = (input: Buffer | string): string => {
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const base64UrlDecode = (input: string): Buffer => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, "base64");
};
const parseSessionTtlHours = (rawValue?: string): number => {
if (!rawValue) return DEFAULT_SESSION_TTL_HOURS;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_SESSION_TTL_HOURS;
}
return parsed;
};
const parseSameSite = (rawValue?: string): AuthSameSite => {
if (!rawValue) return DEFAULT_COOKIE_SAMESITE;
const normalized = rawValue.trim().toLowerCase();
if (normalized === "none" || normalized === "strict" || normalized === "lax") {
return normalized;
}
return DEFAULT_COOKIE_SAMESITE;
};
const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => {
if (!enabled) return Buffer.alloc(0);
const secretFromEnv = env.AUTH_SESSION_SECRET;
if (secretFromEnv && secretFromEnv.trim().length > 0) {
return Buffer.from(secretFromEnv, "utf8");
}
const generated = crypto.randomBytes(32);
const envLabel = env.NODE_ENV ? ` (${env.NODE_ENV})` : "";
console.warn(
`[security] AUTH_SESSION_SECRET is not set${envLabel}. ` +
"Using an ephemeral per-process secret. Sessions will be invalidated on restart."
);
return generated;
};
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
const username = (env.AUTH_USERNAME || "").trim();
const password = env.AUTH_PASSWORD || "";
const enabled = username.length > 0 && password.length > 0;
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
return {
enabled,
username,
password,
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
cookieSameSite,
secret: resolveAuthSecret(enabled, env),
};
};
const signToken = (secret: Buffer, payloadB64: string): Buffer =>
crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest();
const safeCompare = (left: string, right: string): boolean => {
const leftHash = crypto.createHash("sha256").update(left, "utf8").digest();
const rightHash = crypto.createHash("sha256").update(right, "utf8").digest();
return crypto.timingSafeEqual(leftHash, rightHash);
};
export const verifyCredentials = (
config: AuthConfig,
inputUsername: string,
inputPassword: string
): boolean => {
if (!config.enabled) return false;
return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword);
};
export const createAuthSessionToken = (config: AuthConfig, username: string): string => {
if (!config.enabled) {
throw new Error("Authentication is not enabled.");
}
const issuedAt = Date.now();
const payload: AuthSession = {
username,
iat: issuedAt,
exp: issuedAt + config.sessionTtlMs,
};
const payloadJson = JSON.stringify(payload);
const payloadB64 = base64UrlEncode(payloadJson);
const sigB64 = base64UrlEncode(signToken(config.secret, payloadB64));
return `${payloadB64}.${sigB64}`;
};
export const validateAuthSessionToken = (
config: AuthConfig,
token: string | undefined | null
): AuthSession | null => {
if (!config.enabled || !token || typeof token !== "string") {
return null;
}
const parts = token.split(".");
if (parts.length !== 2) {
return null;
}
const [payloadB64, sigB64] = parts;
try {
const expectedSig = signToken(config.secret, payloadB64);
const providedSig = base64UrlDecode(sigB64);
if (providedSig.length !== expectedSig.length) return null;
if (!crypto.timingSafeEqual(providedSig, expectedSig)) return null;
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
const payload = JSON.parse(payloadJson) as Partial<AuthSession>;
if (
typeof payload.username !== "string" ||
typeof payload.iat !== "number" ||
typeof payload.exp !== "number"
) {
return null;
}
if (Date.now() > payload.exp) {
return null;
}
return payload as AuthSession;
} catch {
return null;
}
};
export const parseCookieHeader = (
cookieHeader: string | undefined
): Record<string, string> => {
if (!cookieHeader) return {};
return cookieHeader.split(";").reduce<Record<string, string>>((acc, part) => {
const [rawKey, ...rest] = part.trim().split("=");
if (!rawKey) return acc;
const value = rest.join("=");
acc[decodeURIComponent(rawKey)] = decodeURIComponent(value || "");
return acc;
}, {});
};
export const getAuthSessionFromCookie = (
cookieHeader: string | undefined,
config: AuthConfig
): AuthSession | null => {
if (!config.enabled) return null;
const cookies = parseCookieHeader(cookieHeader);
const token = cookies[config.cookieName];
return validateAuthSessionToken(config, token);
};
export const buildAuthCookieOptions = (
secure: boolean,
sameSite: AuthSameSite,
maxAgeMs?: number
) => {
const normalizedSameSite = sameSite === "none" ? "none" : sameSite;
const options: {
httpOnly: boolean;
sameSite: AuthSameSite;
secure: boolean;
path: string;
maxAge?: number;
} = {
httpOnly: true,
sameSite: normalizedSameSite,
secure: normalizedSameSite === "none" ? true : secure,
path: "/",
};
if (typeof maxAgeMs === "number" && Number.isFinite(maxAgeMs) && maxAgeMs > 0) {
options.maxAge = maxAgeMs;
}
return options;
};
+141 -1
View File
@@ -23,6 +23,13 @@ import {
getCsrfTokenHeader,
getOriginFromReferer,
} from "./security";
import {
buildAuthConfig,
buildAuthCookieOptions,
createAuthSessionToken,
getAuthSessionFromCookie,
verifyCredentials,
} from "./auth";
dotenv.config();
@@ -96,6 +103,13 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
console.log("Allowed origins:", allowedOrigins);
const authConfig = buildAuthConfig();
if (authConfig.enabled) {
console.log(`[auth] Enabled for user "${authConfig.username}".`);
} else {
console.log("[auth] Disabled (AUTH_USERNAME/AUTH_PASSWORD not set).");
}
const uploadDir = path.resolve(__dirname, "../uploads");
const moveFile = async (source: string, destination: string) => {
@@ -374,6 +388,52 @@ app.get("/csrf-token", (req, res) => {
});
});
const isRequestSecure = (req: express.Request): boolean => {
if (req.secure) return true;
const forwardedProto = req.headers["x-forwarded-proto"];
if (Array.isArray(forwardedProto)) {
return forwardedProto[0] === "https";
}
return forwardedProto === "https";
};
const authExemptPaths = new Set([
"/csrf-token",
"/health",
"/auth/status",
"/auth/login",
"/auth/logout",
]);
const authMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
if (!authConfig.enabled) {
return next();
}
if (req.method === "OPTIONS") {
return next();
}
if (authExemptPaths.has(req.path)) {
return next();
}
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
if (!session) {
return res.status(401).json({
error: "Unauthorized",
message: "Authentication required",
});
}
res.locals.authUser = session.username;
next();
};
// CSRF validation middleware for state-changing requests
const csrfProtectionMiddleware = (
req: express.Request,
@@ -438,9 +498,75 @@ const csrfProtectionMiddleware = (
next();
};
// Apply CSRF protection to all routes
// Apply authentication and CSRF protection to all routes
app.use(authMiddleware);
app.use(csrfProtectionMiddleware);
const authLoginSchema = z.object({
username: z.string().trim().min(1).max(200),
password: z.string().min(1).max(512),
});
app.get("/auth/status", (req, res) => {
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
res.setHeader("Cache-Control", "no-store");
res.json({
enabled: authConfig.enabled,
authenticated: Boolean(session),
user: session ? { username: session.username } : null,
});
});
app.post("/auth/login", (req, res) => {
if (!authConfig.enabled) {
return res.status(404).json({
error: "Authentication disabled",
message: "Authentication is not enabled on this server.",
});
}
const parsed = authLoginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "Invalid payload",
message: "Username and password are required.",
});
}
const { username, password } = parsed.data;
if (!verifyCredentials(authConfig, username, password)) {
return res.status(401).json({
error: "Unauthorized",
message: "Invalid username or password.",
});
}
const token = createAuthSessionToken(authConfig, authConfig.username);
res.cookie(
authConfig.cookieName,
token,
buildAuthCookieOptions(
isRequestSecure(req),
authConfig.cookieSameSite,
authConfig.sessionTtlMs
)
);
res.setHeader("Cache-Control", "no-store");
return res.json({
authenticated: true,
user: { username: authConfig.username },
});
});
app.post("/auth/logout", (req, res) => {
res.clearCookie(
authConfig.cookieName,
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
);
res.setHeader("Cache-Control", "no-store");
return res.json({ authenticated: false });
});
const filesFieldSchema = z
.union([z.record(z.string(), z.any()), z.null()])
.optional()
@@ -630,6 +756,20 @@ interface User {
const roomUsers = new Map<string, User[]>();
if (authConfig.enabled) {
io.use((socket, next) => {
const session = getAuthSessionFromCookie(
socket.request.headers.cookie,
authConfig
);
if (!session) {
return next(new Error("Unauthorized"));
}
(socket as { authUser?: string }).authUser = session.username;
return next();
});
}
io.on("connection", (socket) => {
socket.on(
"join-room",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<rect width="10" height="10" fill="#4f46e5" />
</svg>

After

Width:  |  Height:  |  Size: 140 B

+11 -1
View File
@@ -4,7 +4,14 @@ import { defineConfig, devices } from "@playwright/test";
const FRONTEND_PORT = 5173;
const BACKEND_PORT = 8000;
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
const BACKEND_URL = process.env.API_URL || http://localhost:${BACKEND_PORT}`;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || "e2e-auth-secret";
process.env.AUTH_USERNAME = AUTH_USERNAME;
process.env.AUTH_PASSWORD = AUTH_PASSWORD;
process.env.AUTH_SESSION_SECRET = AUTH_SESSION_SECRET;
/**
* Playwright configuration for E2E browser testing
@@ -97,6 +104,9 @@ export default defineConfig({
DATABASE_URL: "file:./dev.db",
FRONTEND_URL,
CSRF_MAX_REQUESTS: "1000",
AUTH_USERNAME,
AUTH_PASSWORD,
AUTH_SESSION_SECRET,
},
},
{
+27
View File
@@ -0,0 +1,27 @@
import { test, expect } from "./fixtures";
test.describe("Authentication", () => {
test("should require login and allow logout", async ({ page }) => {
const username = process.env.AUTH_USERNAME || "admin";
const password = process.env.AUTH_PASSWORD || "admin";
await page.context().clearCookies();
await page.goto("/");
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
await page.getByLabel("Username").fill(username);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
// Wait for the dashboard to fully load (login updates state, no URL change)
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
await page.goto("/settings");
const logoutButton = page.getByRole("button", { name: /Log out/i });
await expect(logoutButton).toBeVisible();
await logoutButton.click();
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
});
});
+8 -1
View File
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import { ensurePageAuthenticated } from "./helpers/auth";
import {
createDrawing,
deleteDrawing,
@@ -42,6 +43,8 @@ test.describe("Real-time Collaboration", () => {
try {
// Both users navigate to the same drawing
await ensurePageAuthenticated(page1);
await ensurePageAuthenticated(page2);
await page1.goto(`/editor/${drawing.id}`);
await page2.goto(`/editor/${drawing.id}`);
@@ -88,6 +91,8 @@ test.describe("Real-time Collaboration", () => {
try {
// Both users navigate to the same drawing
await ensurePageAuthenticated(page1);
await ensurePageAuthenticated(page2);
await page1.goto(`/editor/${drawing.id}`);
await page2.goto(`/editor/${drawing.id}`);
@@ -190,6 +195,8 @@ test.describe("Real-time Collaboration", () => {
const page2 = await context2.newPage();
try {
await ensurePageAuthenticated(page1);
await ensurePageAuthenticated(page2);
await page1.goto(`/editor/${drawing.id}`);
await page2.goto(`/editor/${drawing.id}`);
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import type { Locator, Page } from "@playwright/test";
import {
API_URL,
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import * as path from "path";
import * as fs from "fs";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import {
API_URL,
createDrawing,
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import {
API_URL,
createDrawing,
+24
View File
@@ -0,0 +1,24 @@
import { test as base, expect } from "@playwright/test";
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
export const test = base;
test.beforeEach(async ({ page }) => {
// Navigate to root to check if we need to login
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
// If we see the login page, perform login
const loginText = page.getByText("Sign in to access your drawings");
if (await loginText.isVisible({ timeout: 2000 }).catch(() => false)) {
await page.getByLabel("Username").fill(AUTH_USERNAME);
await page.getByLabel("Password").fill(AUTH_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
// Wait for dashboard to load
await page.getByPlaceholder("Search drawings...").waitFor({ state: "visible", timeout: 15000 });
}
});
export { expect };
+60
View File
@@ -5,6 +5,58 @@ const DEFAULT_BACKEND_PORT = 8000;
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
// Track authenticated API contexts
const authenticatedContexts = new WeakSet<APIRequestContext>();
/**
* Ensure the API request context is authenticated
*/
export async function ensureAuthenticated(request: APIRequestContext): Promise<void> {
if (authenticatedContexts.has(request)) return;
// Check current auth status
const statusResp = await request.get(`${API_URL}/auth/status`);
if (!statusResp.ok()) {
throw new Error(`Failed to check auth status: ${statusResp.status()}`);
}
const status = (await statusResp.json()) as { enabled: boolean; authenticated: boolean };
if (!status.enabled) {
// Auth is disabled, mark as "authenticated"
authenticatedContexts.add(request);
return;
}
if (status.authenticated) {
authenticatedContexts.add(request);
return;
}
// Need to login
const csrfHeaders = await getCsrfHeaders(request);
const loginResp = await request.post(`${API_URL}/auth/login`, {
headers: {
"Content-Type": "application/json",
...csrfHeaders,
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!loginResp.ok()) {
const text = await loginResp.text();
throw new Error(`API authentication failed: ${loginResp.status()} ${text}`);
}
authenticatedContexts.add(request);
}
type CsrfTokenResponse = {
token: string;
header?: string;
@@ -137,6 +189,8 @@ export async function createDrawing(
request: APIRequestContext,
overrides: CreateDrawingOptions = {}
): Promise<DrawingRecord> {
await ensureAuthenticated(request);
const payload = { ...defaultDrawingPayload(), ...overrides };
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
@@ -169,6 +223,7 @@ export async function getDrawing(
request: APIRequestContext,
id: string
): Promise<DrawingRecord> {
await ensureAuthenticated(request);
const response = await request.get(`${API_URL}/drawings/${id}`);
expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord;
@@ -178,6 +233,7 @@ export async function deleteDrawing(
request: APIRequestContext,
id: string
): Promise<void> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
@@ -202,6 +258,7 @@ export async function listDrawings(
request: APIRequestContext,
options: ListDrawingsOptions = {}
): Promise<DrawingRecord[]> {
await ensureAuthenticated(request);
const params = new URLSearchParams();
if (options.search) params.set("search", options.search);
if (options.collectionId !== undefined) {
@@ -224,6 +281,7 @@ export async function createCollection(
request: APIRequestContext,
name: string
): Promise<CollectionRecord> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
let response = await request.post(`${API_URL}/collections`, {
@@ -249,6 +307,7 @@ export async function createCollection(
export async function listCollections(
request: APIRequestContext
): Promise<CollectionRecord[]> {
await ensureAuthenticated(request);
const response = await request.get(`${API_URL}/collections`);
expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord[];
@@ -258,6 +317,7 @@ export async function deleteCollection(
request: APIRequestContext,
id: string
): Promise<void> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
+62
View File
@@ -0,0 +1,62 @@
import { APIRequestContext, Page } from "@playwright/test";
import { API_URL, getCsrfHeaders } from "./api";
type AuthStatus = {
enabled: boolean;
authenticated: boolean;
};
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const authStatusCache = new WeakMap<APIRequestContext, AuthStatus>();
const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => {
const cached = authStatusCache.get(request);
// Only use cache if we're already authenticated
if (cached?.authenticated) return cached;
const response = await request.get(`${API_URL}/auth/status`);
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
}
const data = (await response.json()) as AuthStatus;
authStatusCache.set(request, data);
return data;
};
const setAuthStatus = (request: APIRequestContext, status: AuthStatus) => {
authStatusCache.set(request, status);
};
export const ensureApiAuthenticated = async (request: APIRequestContext) => {
const status = await fetchAuthStatus(request);
if (!status.enabled || status.authenticated) {
return;
}
const headers = await getCsrfHeaders(request);
const response = await request.post(`${API_URL}/auth/login`, {
headers: {
"Content-Type": "application/json",
...headers,
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to authenticate test session: ${response.status()} ${text}`);
}
setAuthStatus(request, { enabled: true, authenticated: true });
};
export const ensurePageAuthenticated = async (page: Page) => {
await ensureApiAuthenticated(page.request);
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import * as fs from "fs";
import * as path from "path";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
import {
createDrawing,
deleteDrawing,
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* E2E Tests for Theme Toggle functionality
+6
View File
@@ -4,20 +4,26 @@ import { Editor } from './pages/Editor';
import { Settings } from './pages/Settings';
import { ThemeProvider } from './context/ThemeContext';
import { UploadProvider } from './context/UploadContext';
import { AuthProvider } from './context/AuthContext';
import { AuthGate } from './components/AuthGate';
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UploadProvider>
<Router>
<AuthGate>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/collections" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/editor/:id" element={<Editor />} />
</Routes>
</AuthGate>
</Router>
</UploadProvider>
</AuthProvider>
</ThemeProvider>
);
}
+42 -1
View File
@@ -5,8 +5,21 @@ export const API_URL = import.meta.env.VITE_API_URL || "/api";
export const api = axios.create({
baseURL: API_URL,
withCredentials: true,
});
export type AuthStatus = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
};
let unauthorizedHandler: (() => void) | null = null;
export const setUnauthorizedHandler = (handler: (() => void) | null) => {
unauthorizedHandler = handler;
};
// CSRF Token Management
let csrfToken: string | null = null;
let csrfHeaderName: string = "x-csrf-token";
@@ -18,7 +31,8 @@ let csrfTokenPromise: Promise<void> | null = null;
export const fetchCsrfToken = async (): Promise<void> => {
try {
const response = await axios.get<{ token: string; header: string }>(
`${API_URL}/csrf-token`
`${API_URL}/csrf-token`,
{ withCredentials: true }
);
csrfToken = response.data.token;
csrfHeaderName = response.data.header || "x-csrf-token";
@@ -50,6 +64,11 @@ export const clearCsrfToken = (): void => {
csrfToken = null;
};
export const getCsrfHeaders = async (): Promise<Record<string, string>> => {
await ensureCsrfToken();
return csrfToken ? { [csrfHeaderName]: csrfToken } : {};
};
// Add request interceptor to include CSRF token
api.interceptors.request.use(
async (config) => {
@@ -70,6 +89,10 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
unauthorizedHandler?.();
}
// If we get a 403 with CSRF error, clear token and retry once
if (
error.response?.status === 403 &&
@@ -92,6 +115,24 @@ api.interceptors.response.use(
}
);
export const getAuthStatus = async (): Promise<AuthStatus> => {
const response = await api.get<AuthStatus>("/auth/status");
return response.data;
};
export const login = async (username: string, password: string) => {
const response = await api.post<{ authenticated: boolean }>("/auth/login", {
username,
password,
});
return response.data;
};
export const logout = async () => {
const response = await api.post<{ authenticated: boolean }>("/auth/logout");
return response.data;
};
const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { useAuth } from "../context/AuthContext";
import { Login } from "../pages/Login";
export const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { state } = useAuth();
if (state.loading) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 text-slate-700 dark:text-neutral-200 transition-colors duration-200">
<Loader2 className="h-8 w-8 animate-spin mb-3" />
<p className="text-sm font-semibold">Loading session...</p>
</div>
);
}
if (state.statusError) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 text-slate-700 dark:text-neutral-200 transition-colors duration-200 px-4 text-center">
<p className="text-sm font-semibold mb-2">{state.statusError}</p>
<p className="text-xs text-slate-500 dark:text-neutral-400">Please refresh to try again.</p>
</div>
);
}
if (state.enabled && !state.authenticated) {
return <Login />;
}
return <>{children}</>;
};
+111
View File
@@ -0,0 +1,111 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import * as api from "../api";
type AuthState = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
loading: boolean;
statusError: string | null;
};
type AuthContextValue = {
state: AuthState;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshStatus: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<AuthState>({
enabled: false,
authenticated: false,
user: null,
loading: true,
statusError: null,
});
const refreshStatus = useCallback(async () => {
setState((prev) => ({
...prev,
loading: true,
}));
try {
const status = await api.getAuthStatus();
setState({
enabled: status.enabled,
authenticated: status.authenticated,
user: status.user,
loading: false,
statusError: null,
});
} catch (error) {
console.error("Failed to fetch auth status:", error);
setState((prev) => ({
...prev,
authenticated: false,
user: null,
loading: false,
statusError: prev.statusError || "Unable to reach authentication service.",
}));
}
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
useEffect(() => {
api.setUnauthorizedHandler(() => {
setState((prev) => ({
...prev,
authenticated: false,
user: null,
}));
});
return () => api.setUnauthorizedHandler(null);
}, []);
const login = useCallback(
async (username: string, password: string) => {
await api.login(username, password);
await refreshStatus();
},
[refreshStatus]
);
const logout = useCallback(async () => {
await api.logout();
await refreshStatus();
}, [refreshStatus]);
const value = useMemo<AuthContextValue>(
() => ({
state,
login,
logout,
refreshStatus,
}),
[state, login, logout, refreshStatus]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
+1
View File
@@ -114,6 +114,7 @@ export const Editor: React.FC = () => {
const socket = io(socketUrl, {
path: '/socket.io',
transports: ['websocket', 'polling'],
withCredentials: true,
});
socketRef.current = socket;
+85
View File
@@ -0,0 +1,85 @@
import React, { useState } from "react";
import { Loader2 } from "lucide-react";
import { Logo } from "../components/Logo";
import { useAuth } from "../context/AuthContext";
export const Login: React.FC = () => {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(username.trim(), password);
} catch (err) {
console.error("Login failed:", err);
setError("Invalid username or password.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 px-4 py-12 transition-colors duration-200">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-3xl border-2 border-black dark:border-neutral-700 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] p-8">
<div className="flex flex-col items-center text-center">
<Logo className="h-16 w-16 mb-4" />
<h1 className="text-4xl text-slate-900 dark:text-white" style={{ fontFamily: "Excalifont" }}>
ExcaliDash
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
Sign in to access your drawings
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Username
<input
type="text"
name="username"
autoComplete="username"
required
value={username}
onChange={(event) => setUsername(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Password
<input
type="password"
name="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
{error && (
<p className="text-sm text-rose-600 dark:text-rose-400 font-semibold">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white px-4 py-3 text-base font-bold shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] transition-all duration-200 hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : "Sign in"}
</button>
</form>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More