feat: implement basic authentication system
This commit is contained in:
@@ -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.
|
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
|
# Development
|
||||||
|
|
||||||
## Clone the Repository
|
## Clone the Repository
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
FRONTEND_URL=http://localhost:6767
|
||||||
|
# Optional auth cookie settings: lax | strict | none
|
||||||
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -23,6 +23,13 @@ import {
|
|||||||
getCsrfTokenHeader,
|
getCsrfTokenHeader,
|
||||||
getOriginFromReferer,
|
getOriginFromReferer,
|
||||||
} from "./security";
|
} from "./security";
|
||||||
|
import {
|
||||||
|
buildAuthConfig,
|
||||||
|
buildAuthCookieOptions,
|
||||||
|
createAuthSessionToken,
|
||||||
|
getAuthSessionFromCookie,
|
||||||
|
verifyCredentials,
|
||||||
|
} from "./auth";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -96,6 +103,13 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
|
|||||||
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
||||||
console.log("Allowed origins:", allowedOrigins);
|
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 uploadDir = path.resolve(__dirname, "../uploads");
|
||||||
|
|
||||||
const moveFile = async (source: string, destination: string) => {
|
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
|
// CSRF validation middleware for state-changing requests
|
||||||
const csrfProtectionMiddleware = (
|
const csrfProtectionMiddleware = (
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
@@ -438,9 +498,75 @@ const csrfProtectionMiddleware = (
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply CSRF protection to all routes
|
// Apply authentication and CSRF protection to all routes
|
||||||
|
app.use(authMiddleware);
|
||||||
app.use(csrfProtectionMiddleware);
|
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
|
const filesFieldSchema = z
|
||||||
.union([z.record(z.string(), z.any()), z.null()])
|
.union([z.record(z.string(), z.any()), z.null()])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -630,6 +756,20 @@ interface User {
|
|||||||
|
|
||||||
const roomUsers = new Map<string, 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) => {
|
io.on("connection", (socket) => {
|
||||||
socket.on(
|
socket.on(
|
||||||
"join-room",
|
"join-room",
|
||||||
|
|||||||
@@ -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 |
@@ -4,7 +4,14 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
const FRONTEND_PORT = 5173;
|
const FRONTEND_PORT = 5173;
|
||||||
const BACKEND_PORT = 8000;
|
const BACKEND_PORT = 8000;
|
||||||
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
|
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
|
* Playwright configuration for E2E browser testing
|
||||||
@@ -97,6 +104,9 @@ export default defineConfig({
|
|||||||
DATABASE_URL: "file:./dev.db",
|
DATABASE_URL: "file:./dev.db",
|
||||||
FRONTEND_URL,
|
FRONTEND_URL,
|
||||||
CSRF_MAX_REQUESTS: "1000",
|
CSRF_MAX_REQUESTS: "1000",
|
||||||
|
AUTH_USERNAME,
|
||||||
|
AUTH_PASSWORD,
|
||||||
|
AUTH_SESSION_SECRET,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
|
import { ensurePageAuthenticated } from "./helpers/auth";
|
||||||
import {
|
import {
|
||||||
createDrawing,
|
createDrawing,
|
||||||
deleteDrawing,
|
deleteDrawing,
|
||||||
@@ -42,6 +43,8 @@ test.describe("Real-time Collaboration", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Both users navigate to the same drawing
|
// Both users navigate to the same drawing
|
||||||
|
await ensurePageAuthenticated(page1);
|
||||||
|
await ensurePageAuthenticated(page2);
|
||||||
await page1.goto(`/editor/${drawing.id}`);
|
await page1.goto(`/editor/${drawing.id}`);
|
||||||
await page2.goto(`/editor/${drawing.id}`);
|
await page2.goto(`/editor/${drawing.id}`);
|
||||||
|
|
||||||
@@ -88,6 +91,8 @@ test.describe("Real-time Collaboration", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Both users navigate to the same drawing
|
// Both users navigate to the same drawing
|
||||||
|
await ensurePageAuthenticated(page1);
|
||||||
|
await ensurePageAuthenticated(page2);
|
||||||
await page1.goto(`/editor/${drawing.id}`);
|
await page1.goto(`/editor/${drawing.id}`);
|
||||||
await page2.goto(`/editor/${drawing.id}`);
|
await page2.goto(`/editor/${drawing.id}`);
|
||||||
|
|
||||||
@@ -190,6 +195,8 @@ test.describe("Real-time Collaboration", () => {
|
|||||||
const page2 = await context2.newPage();
|
const page2 = await context2.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensurePageAuthenticated(page1);
|
||||||
|
await ensurePageAuthenticated(page2);
|
||||||
await page1.goto(`/editor/${drawing.id}`);
|
await page1.goto(`/editor/${drawing.id}`);
|
||||||
await page2.goto(`/editor/${drawing.id}`);
|
await page2.goto(`/editor/${drawing.id}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
API_URL,
|
API_URL,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
API_URL,
|
API_URL,
|
||||||
createDrawing,
|
createDrawing,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
API_URL,
|
API_URL,
|
||||||
createDrawing,
|
createDrawing,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -5,6 +5,58 @@ const DEFAULT_BACKEND_PORT = 8000;
|
|||||||
|
|
||||||
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
|
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 = {
|
type CsrfTokenResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
header?: string;
|
header?: string;
|
||||||
@@ -137,6 +189,8 @@ export async function createDrawing(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
overrides: CreateDrawingOptions = {}
|
overrides: CreateDrawingOptions = {}
|
||||||
): Promise<DrawingRecord> {
|
): Promise<DrawingRecord> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
|
|
||||||
const payload = { ...defaultDrawingPayload(), ...overrides };
|
const payload = { ...defaultDrawingPayload(), ...overrides };
|
||||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||||
|
|
||||||
@@ -169,6 +223,7 @@ export async function getDrawing(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
id: string
|
id: string
|
||||||
): Promise<DrawingRecord> {
|
): Promise<DrawingRecord> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
const response = await request.get(`${API_URL}/drawings/${id}`);
|
const response = await request.get(`${API_URL}/drawings/${id}`);
|
||||||
expect(response.ok()).toBe(true);
|
expect(response.ok()).toBe(true);
|
||||||
return (await response.json()) as DrawingRecord;
|
return (await response.json()) as DrawingRecord;
|
||||||
@@ -178,6 +233,7 @@ export async function deleteDrawing(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
const headers = await withCsrfHeaders(request);
|
const headers = await withCsrfHeaders(request);
|
||||||
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||||
|
|
||||||
@@ -202,6 +258,7 @@ export async function listDrawings(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
options: ListDrawingsOptions = {}
|
options: ListDrawingsOptions = {}
|
||||||
): Promise<DrawingRecord[]> {
|
): Promise<DrawingRecord[]> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options.search) params.set("search", options.search);
|
if (options.search) params.set("search", options.search);
|
||||||
if (options.collectionId !== undefined) {
|
if (options.collectionId !== undefined) {
|
||||||
@@ -224,6 +281,7 @@ export async function createCollection(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
name: string
|
name: string
|
||||||
): Promise<CollectionRecord> {
|
): Promise<CollectionRecord> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||||
|
|
||||||
let response = await request.post(`${API_URL}/collections`, {
|
let response = await request.post(`${API_URL}/collections`, {
|
||||||
@@ -249,6 +307,7 @@ export async function createCollection(
|
|||||||
export async function listCollections(
|
export async function listCollections(
|
||||||
request: APIRequestContext
|
request: APIRequestContext
|
||||||
): Promise<CollectionRecord[]> {
|
): Promise<CollectionRecord[]> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
const response = await request.get(`${API_URL}/collections`);
|
const response = await request.get(`${API_URL}/collections`);
|
||||||
expect(response.ok()).toBe(true);
|
expect(response.ok()).toBe(true);
|
||||||
return (await response.json()) as CollectionRecord[];
|
return (await response.json()) as CollectionRecord[];
|
||||||
@@ -258,6 +317,7 @@ export async function deleteCollection(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await ensureAuthenticated(request);
|
||||||
const headers = await withCsrfHeaders(request);
|
const headers = await withCsrfHeaders(request);
|
||||||
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
createDrawing,
|
createDrawing,
|
||||||
deleteDrawing,
|
deleteDrawing,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E Tests for Theme Toggle functionality
|
* E2E Tests for Theme Toggle functionality
|
||||||
|
|||||||
+16
-10
@@ -4,20 +4,26 @@ import { Editor } from './pages/Editor';
|
|||||||
import { Settings } from './pages/Settings';
|
import { Settings } from './pages/Settings';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
import { UploadProvider } from './context/UploadContext';
|
import { UploadProvider } from './context/UploadContext';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { AuthGate } from './components/AuthGate';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<UploadProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<UploadProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<AuthGate>
|
||||||
<Route path="/collections" element={<Dashboard />} />
|
<Routes>
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/editor/:id" element={<Editor />} />
|
<Route path="/collections" element={<Dashboard />} />
|
||||||
</Routes>
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Router>
|
<Route path="/editor/:id" element={<Editor />} />
|
||||||
</UploadProvider>
|
</Routes>
|
||||||
|
</AuthGate>
|
||||||
|
</Router>
|
||||||
|
</UploadProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,21 @@ export const API_URL = import.meta.env.VITE_API_URL || "/api";
|
|||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: API_URL,
|
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
|
// CSRF Token Management
|
||||||
let csrfToken: string | null = null;
|
let csrfToken: string | null = null;
|
||||||
let csrfHeaderName: string = "x-csrf-token";
|
let csrfHeaderName: string = "x-csrf-token";
|
||||||
@@ -18,7 +31,8 @@ let csrfTokenPromise: Promise<void> | null = null;
|
|||||||
export const fetchCsrfToken = async (): Promise<void> => {
|
export const fetchCsrfToken = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<{ token: string; header: string }>(
|
const response = await axios.get<{ token: string; header: string }>(
|
||||||
`${API_URL}/csrf-token`
|
`${API_URL}/csrf-token`,
|
||||||
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
csrfToken = response.data.token;
|
csrfToken = response.data.token;
|
||||||
csrfHeaderName = response.data.header || "x-csrf-token";
|
csrfHeaderName = response.data.header || "x-csrf-token";
|
||||||
@@ -50,6 +64,11 @@ export const clearCsrfToken = (): void => {
|
|||||||
csrfToken = null;
|
csrfToken = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCsrfHeaders = async (): Promise<Record<string, string>> => {
|
||||||
|
await ensureCsrfToken();
|
||||||
|
return csrfToken ? { [csrfHeaderName]: csrfToken } : {};
|
||||||
|
};
|
||||||
|
|
||||||
// Add request interceptor to include CSRF token
|
// Add request interceptor to include CSRF token
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
@@ -70,6 +89,10 @@ api.interceptors.request.use(
|
|||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
unauthorizedHandler?.();
|
||||||
|
}
|
||||||
|
|
||||||
// If we get a 403 with CSRF error, clear token and retry once
|
// If we get a 403 with CSRF error, clear token and retry once
|
||||||
if (
|
if (
|
||||||
error.response?.status === 403 &&
|
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 => {
|
const coerceTimestamp = (value: string | number | Date): number => {
|
||||||
if (typeof value === "number") return value;
|
if (typeof value === "number") return value;
|
||||||
if (value instanceof Date) return value.getTime();
|
if (value instanceof Date) return value.getTime();
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -114,6 +114,7 @@ export const Editor: React.FC = () => {
|
|||||||
const socket = io(socketUrl, {
|
const socket = io(socketUrl, {
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user