From 20ef4ee295280e8a53984b19e5dda99311235679 Mon Sep 17 00:00:00 2001 From: Adrian Acala Date: Fri, 16 Jan 2026 21:34:58 -0800 Subject: [PATCH] feat: implement basic authentication system --- README.md | 21 +++ backend/.env.example | 4 +- backend/src/__tests__/auth.test.ts | 62 ++++++++ backend/src/auth.ts | 215 ++++++++++++++++++++++++++ backend/src/index.ts | 142 ++++++++++++++++- e2e/fixtures/small-image.svg | 3 + e2e/playwright.config.ts | 12 +- e2e/tests/auth.spec.ts | 27 ++++ e2e/tests/collaboration.spec.ts | 9 +- e2e/tests/dashboard-workflows.spec.ts | 2 +- e2e/tests/drag-and-drop.spec.ts | 2 +- e2e/tests/drawing-crud.spec.ts | 2 +- e2e/tests/export-import.spec.ts | 2 +- e2e/tests/fixtures.ts | 24 +++ e2e/tests/helpers/api.ts | 60 +++++++ e2e/tests/helpers/auth.ts | 62 ++++++++ e2e/tests/image-persistence.spec.ts | 2 +- e2e/tests/search-and-sort.spec.ts | 2 +- e2e/tests/theme-toggle.spec.ts | 2 +- frontend/src/App.tsx | 26 ++-- frontend/src/api/index.ts | 43 +++++- frontend/src/components/AuthGate.tsx | 32 ++++ frontend/src/context/AuthContext.tsx | 111 +++++++++++++ frontend/src/pages/Editor.tsx | 1 + frontend/src/pages/Login.tsx | 85 ++++++++++ frontend/src/pages/Settings.tsx | 45 +++++- 26 files changed, 975 insertions(+), 23 deletions(-) create mode 100644 backend/src/__tests__/auth.test.ts create mode 100644 backend/src/auth.ts create mode 100644 e2e/fixtures/small-image.svg create mode 100644 e2e/tests/auth.spec.ts create mode 100644 e2e/tests/fixtures.ts create mode 100644 e2e/tests/helpers/auth.ts create mode 100644 frontend/src/components/AuthGate.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/pages/Login.tsx diff --git a/README.md b/README.md index 9c2510b..8f508af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example index da110e9..360ac6f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,4 +2,6 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db -FRONTEND_URL=http://localhost:6767 \ No newline at end of file +FRONTEND_URL=http://localhost:6767 +# Optional auth cookie settings: lax | strict | none +AUTH_COOKIE_SAMESITE=lax diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts new file mode 100644 index 0000000..17ecc8e --- /dev/null +++ b/backend/src/__tests__/auth.test.ts @@ -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"); + }); +}); diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..0325b4f --- /dev/null +++ b/backend/src/auth.ts @@ -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; + 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 => { + if (!cookieHeader) return {}; + return cookieHeader.split(";").reduce>((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; +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 5e2025f..6d65121 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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(); +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", diff --git a/e2e/fixtures/small-image.svg b/e2e/fixtures/small-image.svg new file mode 100644 index 0000000..264ecae --- /dev/null +++ b/e2e/fixtures/small-image.svg @@ -0,0 +1,3 @@ + + + diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f3d13ce..fcbe5f1 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -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, }, }, { diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..dfb36ec --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/collaboration.spec.ts b/e2e/tests/collaboration.spec.ts index 1ab8bf8..0c9691f 100644 --- a/e2e/tests/collaboration.spec.ts +++ b/e2e/tests/collaboration.spec.ts @@ -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}`); diff --git a/e2e/tests/dashboard-workflows.spec.ts b/e2e/tests/dashboard-workflows.spec.ts index e1749b1..92e3b9f 100644 --- a/e2e/tests/dashboard-workflows.spec.ts +++ b/e2e/tests/dashboard-workflows.spec.ts @@ -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, diff --git a/e2e/tests/drag-and-drop.spec.ts b/e2e/tests/drag-and-drop.spec.ts index 70c36be..8c76545 100644 --- a/e2e/tests/drag-and-drop.spec.ts +++ b/e2e/tests/drag-and-drop.spec.ts @@ -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 { diff --git a/e2e/tests/drawing-crud.spec.ts b/e2e/tests/drawing-crud.spec.ts index d2004fc..431bba8 100644 --- a/e2e/tests/drawing-crud.spec.ts +++ b/e2e/tests/drawing-crud.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures"; import { API_URL, createDrawing, diff --git a/e2e/tests/export-import.spec.ts b/e2e/tests/export-import.spec.ts index 251038d..1de49e9 100644 --- a/e2e/tests/export-import.spec.ts +++ b/e2e/tests/export-import.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures"; import { API_URL, createDrawing, diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts new file mode 100644 index 0000000..f73bb1e --- /dev/null +++ b/e2e/tests/fixtures.ts @@ -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 }; diff --git a/e2e/tests/helpers/api.ts b/e2e/tests/helpers/api.ts index 6805e63..3266c10 100644 --- a/e2e/tests/helpers/api.ts +++ b/e2e/tests/helpers/api.ts @@ -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(); + +/** + * Ensure the API request context is authenticated + */ +export async function ensureAuthenticated(request: APIRequestContext): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await ensureAuthenticated(request); const headers = await withCsrfHeaders(request); let response = await request.delete(`${API_URL}/collections/${id}`, { headers }); diff --git a/e2e/tests/helpers/auth.ts b/e2e/tests/helpers/auth.ts new file mode 100644 index 0000000..1e7effe --- /dev/null +++ b/e2e/tests/helpers/auth.ts @@ -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(); + +const fetchAuthStatus = async (request: APIRequestContext): Promise => { + 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); +}; diff --git a/e2e/tests/image-persistence.spec.ts b/e2e/tests/image-persistence.spec.ts index 5e78ff5..637db7f 100644 --- a/e2e/tests/image-persistence.spec.ts +++ b/e2e/tests/image-persistence.spec.ts @@ -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 { diff --git a/e2e/tests/search-and-sort.spec.ts b/e2e/tests/search-and-sort.spec.ts index 7c0c268..0f14ec3 100644 --- a/e2e/tests/search-and-sort.spec.ts +++ b/e2e/tests/search-and-sort.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures"; import { createDrawing, deleteDrawing, diff --git a/e2e/tests/theme-toggle.spec.ts b/e2e/tests/theme-toggle.spec.ts index 2b8efb9..a282612 100644 --- a/e2e/tests/theme-toggle.spec.ts +++ b/e2e/tests/theme-toggle.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures"; /** * E2E Tests for Theme Toggle functionality diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efc99e9..5ea0690 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - - - } /> - } /> - } /> - } /> - - - + + + + + + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1ea40db..3a6e0bc 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 | null = null; export const fetchCsrfToken = async (): Promise => { 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> => { + 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 => { + const response = await api.get("/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(); diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..c24db04 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -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 ( +
+ +

Loading session...

+
+ ); + } + + if (state.statusError) { + return ( +
+

{state.statusError}

+

Please refresh to try again.

+
+ ); + } + + if (state.enabled && !state.authenticated) { + return ; + } + + return <>{children}; +}; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..02e388c --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -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; + logout: () => Promise; + refreshStatus: () => Promise; +}; + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [state, setState] = useState({ + 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( + () => ({ + state, + login, + logout, + refreshStatus, + }), + [state, login, logout, refreshStatus] + ); + + return {children}; +}; + +export const useAuth = (): AuthContextValue => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 9e65f10..48d5cf0 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -114,6 +114,7 @@ export const Editor: React.FC = () => { const socket = io(socketUrl, { path: '/socket.io', transports: ['websocket', 'polling'], + withCredentials: true, }); socketRef.current = socket; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..354ee48 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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(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 ( +
+
+
+ +

+ ExcaliDash +

+

+ Sign in to access your drawings +

+
+ +
+ + + + + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 93cd1d6..b325b2d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -3,15 +3,17 @@ import { Layout } from '../components/Layout'; import { useNavigate } from 'react-router-dom'; import * as api from '../api'; import type { Collection } from '../types'; -import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive } from 'lucide-react'; +import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive, LogOut } from 'lucide-react'; import { ConfirmModal } from '../components/ConfirmModal'; import { importDrawings } from '../utils/importUtils'; import { useTheme } from '../context/ThemeContext'; +import { useAuth } from '../context/AuthContext'; export const Settings: React.FC = () => { const [collections, setCollections] = useState([]); const navigate = useNavigate(); const { theme, toggleTheme } = useTheme(); + const { state: authState, logout, refreshStatus } = useAuth(); const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null }); const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); @@ -91,6 +93,31 @@ export const Settings: React.FC = () => { + {authState.enabled && authState.authenticated && ( + + )} +