diff --git a/README.md b/README.md index b184b6a..67183e1 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ docker compose up -d When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly: - `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses. +- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one reverse proxy hop (for example: frontend nginx -> backend). This ensures rate limiting and logging use the real client IP from trusted proxy headers. - `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname. ```yaml @@ -131,6 +132,8 @@ backend: environment: # Single URL - FRONTEND_URL=https://excalidash.example.com + # Trust exactly one reverse-proxy hop + - TRUST_PROXY=1 # Or multiple URLs (comma-separated) for local + network access # - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767 frontend: diff --git a/backend/.env.example b/backend/.env.example index 099dcf9..9e4e08a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,7 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db FRONTEND_URL=http://localhost:6767 +TRUST_PROXY=1 JWT_SECRET=change-this-secret-in-production-min-32-chars # Optional Feature Flags (all default to false for backward compatibility) diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 1015eef..99749e7 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -21,6 +21,13 @@ import { type AuthModeService, } from "./auth/authMode"; import { getCsrfValidationClientIds } from "./security/csrfClient"; +import { + clearAuthCookies, + readCookie, + REFRESH_TOKEN_COOKIE_NAME, + setAccessTokenCookie, + setAuthCookies, +} from "./auth/cookies"; interface JwtPayload { userId: string; @@ -380,6 +387,10 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router => bootstrapUserId: BOOTSTRAP_USER_ID, defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, clearAuthEnabledCache: authModeService.clearAuthEnabledCache, + setAuthCookies, + setAccessTokenCookie, + clearAuthCookies, + readRefreshTokenFromRequest: (req) => readCookie(req, REFRESH_TOKEN_COOKIE_NAME), }); registerAdminRoutes({ @@ -401,6 +412,8 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router => getRefreshTokenExpiresAt, config, defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, + setAuthCookies, + requireCsrf, }); registerAccountRoutes({ @@ -414,6 +427,8 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router => config, generateTokens, getRefreshTokenExpiresAt, + setAuthCookies, + requireCsrf, }); return router; diff --git a/backend/src/auth/accountRoutes.ts b/backend/src/auth/accountRoutes.ts index 2a21e3d..fcc7c58 100644 --- a/backend/src/auth/accountRoutes.ts +++ b/backend/src/auth/accountRoutes.ts @@ -34,6 +34,12 @@ type RegisterAccountRoutesDeps = { options?: { impersonatorId?: string } ) => { accessToken: string; refreshToken: string }; getRefreshTokenExpiresAt: () => Date; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + requireCsrf: (req: Request, res: Response) => boolean; }; export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { @@ -48,6 +54,8 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { config, generateTokens, getRefreshTokenExpiresAt, + setAuthCookies, + requireCsrf, } = deps; router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => { @@ -210,6 +218,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { router.put("/profile", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", @@ -261,6 +270,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); } @@ -347,6 +357,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { } const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); + setAuthCookies(req, res, { accessToken, refreshToken }); if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); try { @@ -387,6 +398,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); } @@ -463,6 +475,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); } @@ -528,6 +541,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { } const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); + setAuthCookies(req, res, { accessToken, refreshToken }); if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); try { diff --git a/backend/src/auth/adminRoutes.ts b/backend/src/auth/adminRoutes.ts index 7e5af17..9cf0c3e 100644 --- a/backend/src/auth/adminRoutes.ts +++ b/backend/src/auth/adminRoutes.ts @@ -64,6 +64,12 @@ type RegisterAdminRoutesDeps = { enableRefreshTokenRotation: boolean; }; defaultSystemConfigId: string; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + requireCsrf: (req: Request, res: Response) => boolean; }; export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { @@ -86,11 +92,14 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { getRefreshTokenExpiresAt, config, defaultSystemConfigId, + setAuthCookies, + requireCsrf, } = deps; router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const parsed = registrationToggleSchema.safeParse(req.body); @@ -117,6 +126,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.post("/admins", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const parsed = adminRoleUpdateSchema.safeParse(req.body); @@ -220,6 +230,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const parsed = loginRateLimitUpdateSchema.safeParse(req.body); @@ -265,6 +276,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const parsed = loginRateLimitResetSchema.safeParse(req.body); @@ -302,6 +314,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const parsed = adminCreateUserSchema.safeParse(req.body); @@ -386,6 +399,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const userId = String(req.params.id || "").trim(); @@ -494,6 +508,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; if (req.user.impersonatorId) { @@ -583,6 +598,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; if (!requireAdmin(req, res)) return; const parsed = impersonateSchema.safeParse(req.body); @@ -606,6 +622,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { const { accessToken, refreshToken } = generateTokens(target.id, target.email, { impersonatorId: req.user.id, }); + setAuthCookies(req, res, { accessToken, refreshToken }); if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); diff --git a/backend/src/auth/cookies.ts b/backend/src/auth/cookies.ts new file mode 100644 index 0000000..9322de5 --- /dev/null +++ b/backend/src/auth/cookies.ts @@ -0,0 +1,106 @@ +import type { Request, Response } from "express"; +import ms, { type StringValue } from "ms"; +import { config } from "../config"; + +export const ACCESS_TOKEN_COOKIE_NAME = "excalidash-access-token"; +export const REFRESH_TOKEN_COOKIE_NAME = "excalidash-refresh-token"; + +const DEFAULT_ACCESS_TTL_MS = 15 * 60 * 1000; +const DEFAULT_REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +const parseDurationToMs = (value: string, fallbackMs: number): number => { + const parsed = ms(value as StringValue); + if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return fallbackMs; +}; + +const ACCESS_TOKEN_COOKIE_MAX_AGE_MS = parseDurationToMs( + config.jwtAccessExpiresIn, + DEFAULT_ACCESS_TTL_MS +); +const REFRESH_TOKEN_COOKIE_MAX_AGE_MS = parseDurationToMs( + config.jwtRefreshExpiresIn, + DEFAULT_REFRESH_TTL_MS +); + +const requestUsesHttps = (req: Request): boolean => { + if (req.secure) return true; + const forwardedProto = req.headers["x-forwarded-proto"]; + const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const firstHop = String(raw || "") + .split(",")[0] + .trim() + .toLowerCase(); + return firstHop === "https"; +}; + +const shouldUseSecureCookies = (req: Request): boolean => requestUsesHttps(req); + +const baseCookieOptions = (req: Request) => ({ + httpOnly: true, + secure: shouldUseSecureCookies(req), + sameSite: "lax" as const, + path: "/", +}); + +export const setAuthCookies = ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } +): void => { + res.cookie(ACCESS_TOKEN_COOKIE_NAME, tokens.accessToken, { + ...baseCookieOptions(req), + maxAge: ACCESS_TOKEN_COOKIE_MAX_AGE_MS, + }); + res.cookie(REFRESH_TOKEN_COOKIE_NAME, tokens.refreshToken, { + ...baseCookieOptions(req), + maxAge: REFRESH_TOKEN_COOKIE_MAX_AGE_MS, + }); +}; + +export const setAccessTokenCookie = ( + req: Request, + res: Response, + accessToken: string +): void => { + res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, { + ...baseCookieOptions(req), + maxAge: ACCESS_TOKEN_COOKIE_MAX_AGE_MS, + }); +}; + +export const clearAuthCookies = (req: Request, res: Response): void => { + const options = baseCookieOptions(req); + res.clearCookie(ACCESS_TOKEN_COOKIE_NAME, options); + res.clearCookie(REFRESH_TOKEN_COOKIE_NAME, options); +}; + +export const parseCookieHeader = ( + cookieHeader: string | undefined +): Record => { + if (!cookieHeader) return {}; + + const cookies: Record = {}; + for (const part of cookieHeader.split(";")) { + const [rawKey, ...rawValueParts] = part.split("="); + if (!rawKey || rawValueParts.length === 0) continue; + const key = rawKey.trim(); + if (!key) continue; + const rawValue = rawValueParts.join("=").trim(); + try { + cookies[key] = decodeURIComponent(rawValue); + } catch { + cookies[key] = rawValue; + } + } + return cookies; +}; + +export const readCookie = (req: Request, cookieName: string): string | null => { + const cookies = parseCookieHeader(req.headers.cookie); + const value = cookies[cookieName]; + if (!value || value.trim().length === 0) return null; + return value; +}; diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index fec7d77..89f828d 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -57,6 +57,14 @@ type RegisterCoreRoutesDeps = { bootstrapUserId: string; defaultSystemConfigId: string; clearAuthEnabledCache: () => void; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + setAccessTokenCookie: (req: Request, res: Response, accessToken: string) => void; + clearAuthCookies: (req: Request, res: Response) => void; + readRefreshTokenFromRequest: (req: Request) => string | null; }; class HttpError extends Error { @@ -88,6 +96,10 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { bootstrapUserId, defaultSystemConfigId, clearAuthEnabledCache, + setAuthCookies, + setAccessTokenCookie, + clearAuthCookies, + readRefreshTokenFromRequest, } = deps; const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; @@ -158,6 +170,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { } const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); @@ -257,6 +270,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { } const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); @@ -375,6 +389,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { } const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); @@ -431,7 +446,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { router.post("/refresh", async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; - const { refreshToken: oldRefreshToken } = req.body; + const oldRefreshTokenFromBody = + typeof req.body?.refreshToken === "string" ? req.body.refreshToken : null; + const oldRefreshToken = oldRefreshTokenFromBody || readRefreshTokenFromRequest(req); if (!oldRefreshToken || typeof oldRefreshToken !== "string") { return res.status(400).json({ @@ -513,6 +530,10 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { }); }); + setAuthCookies(req, res, { + accessToken, + refreshToken: newRefreshToken, + }); return res.json({ accessToken, refreshToken: newRefreshToken, @@ -555,6 +576,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { signOptions ); + setAccessTokenCookie(req, res, accessToken); res.json({ accessToken }); } catch { return res.status(401).json({ @@ -571,6 +593,116 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { } }); + router.post("/logout", optionalAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + + clearAuthCookies(req, res); + + if (config.enableRefreshTokenRotation && req.user?.id) { + await prisma.refreshToken.updateMany({ + where: { userId: req.user.id, revoked: false }, + data: { revoked: true }, + }); + } + + return res.json({ ok: true }); + } catch (error) { + console.error("Logout error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to logout", + }); + } + }); + + router.post("/stop-impersonation", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + + if (!req.user.impersonatorId) { + return res.status(409).json({ + error: "Conflict", + message: "Not currently impersonating another user", + }); + } + + const impersonator = await prisma.user.findUnique({ + where: { id: req.user.impersonatorId }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") { + return res.status(403).json({ + error: "Forbidden", + message: "Impersonator account is unavailable or no longer authorized", + }); + } + + const { accessToken, refreshToken } = generateTokens( + impersonator.id, + impersonator.email + ); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { + userId: impersonator.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch (error) { + if (isMissingRefreshTokenTableError(error)) { + return res.status(503).json({ + error: "Service unavailable", + message: "Refresh token storage is unavailable. Please run database migrations.", + }); + } + throw error; + } + } + + return res.json({ + user: { + id: impersonator.id, + username: impersonator.username, + email: impersonator.email, + name: impersonator.name, + role: impersonator.role, + mustResetPassword: impersonator.mustResetPassword, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Stop impersonation error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to stop impersonation", + }); + } + }); + router.get("/me", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; diff --git a/backend/src/auth/schemas.ts b/backend/src/auth/schemas.ts index 148a32f..8fa2ca6 100644 --- a/backend/src/auth/schemas.ts +++ b/backend/src/auth/schemas.ts @@ -1,9 +1,25 @@ import { z } from "zod"; +const productionStrongPasswordMessage = + "Password must be at least 12 characters and include upper, lower, number, and symbol"; + +const strongPasswordPattern = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/; + +const passwordSchema = z + .string() + .min(8) + .max(100) + .refine( + (value) => + process.env.NODE_ENV !== "production" || strongPasswordPattern.test(value), + { message: productionStrongPasswordMessage } + ); + export const registerSchema = z.object({ username: z.string().trim().min(3).max(50).optional(), email: z.string().email().toLowerCase().trim(), - password: z.string().min(8).max(100), + password: passwordSchema, name: z.string().trim().min(1).max(100), }); @@ -34,7 +50,7 @@ export const authEnabledToggleSchema = z.object({ export const adminCreateUserSchema = z.object({ username: z.string().trim().min(3).max(50).optional(), email: z.string().email().toLowerCase().trim(), - password: z.string().min(8).max(100), + password: passwordSchema, name: z.string().trim().min(1).max(100), role: z.enum(["ADMIN", "USER"]).optional(), mustResetPassword: z.boolean().optional(), @@ -74,7 +90,7 @@ export const passwordResetRequestSchema = z.object({ export const passwordResetConfirmSchema = z.object({ token: z.string().min(1), - password: z.string().min(8).max(100), + password: passwordSchema, }); export const updateProfileSchema = z.object({ @@ -88,9 +104,9 @@ export const updateEmailSchema = z.object({ export const changePasswordSchema = z.object({ currentPassword: z.string(), - newPassword: z.string().min(8).max(100), + newPassword: passwordSchema, }); export const mustResetPasswordSchema = z.object({ - newPassword: z.string().min(8).max(100), + newPassword: passwordSchema, }); diff --git a/backend/src/config.ts b/backend/src/config.ts index 2ba6602..6f799fa 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -118,11 +118,20 @@ export const config: Config = { // Validate JWT_SECRET strength in production if (config.nodeEnv === "production") { + const normalizedSecret = config.jwtSecret.trim(); + const insecureJwtSecretPlaceholders = new Set([ + "your-secret-key-change-in-production", + "change-this-secret-in-production-min-32-chars", + ]); + if (config.jwtSecret.length < 32) { throw new Error("JWT_SECRET must be at least 32 characters long in production"); } - if (config.jwtSecret === "your-secret-key-change-in-production") { - throw new Error("JWT_SECRET must be changed from default value in production"); + if ( + insecureJwtSecretPlaceholders.has(normalizedSecret) || + normalizedSecret.toLowerCase().includes("change-this-secret") + ) { + throw new Error("JWT_SECRET must be changed from placeholder/default value in production"); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 6787d57..23da4ba 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -109,15 +109,19 @@ const initializeUploadDir = async () => { const app = express(); -// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx. -// Default to a single trusted proxy hop unless TRUST_PROXY is explicitly configured. -// Set TRUST_PROXY=true only when you fully trust all upstream proxy hops. -const trustProxyConfig = (process.env.TRUST_PROXY ?? "1").trim(); -const trustProxyValue = trustProxyConfig === "true" - ? true - : trustProxyConfig === "false" - ? false - : Number.parseInt(trustProxyConfig, 10) || 1; +// Trust proxy headers (X-Forwarded-For, X-Real-IP) only when explicitly configured. +// Safe default is disabled to avoid spoofed client IPs when running without a trusted proxy. +// Set TRUST_PROXY=1 (or a specific hop count) when deploying behind reverse proxies. +const trustProxyConfig = (process.env.TRUST_PROXY ?? "false").trim(); +const parsedProxyHops = Number.parseInt(trustProxyConfig, 10); +const trustProxyValue = + trustProxyConfig === "true" + ? true + : trustProxyConfig === "false" + ? false + : Number.isFinite(parsedProxyHops) && parsedProxyHops > 0 + ? parsedProxyHops + : false; app.set("trust proxy", trustProxyValue); if (trustProxyValue === true) { diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 40554ae..df16c74 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -4,6 +4,7 @@ import { config } from "../config"; import { PrismaClient } from "../generated/client"; import { prisma as defaultPrisma } from "../db/prisma"; import { createAuthModeService, type AuthModeService } from "../auth/authMode"; +import { ACCESS_TOKEN_COOKIE_NAME, readCookie } from "../auth/cookies"; // Extend Express Request type to include user declare global { @@ -46,14 +47,14 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { const extractToken = (req: Request): string | null => { const authHeader = req.headers.authorization; - if (!authHeader || typeof authHeader !== "string") return null; - - const parts = authHeader.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") { - return null; + if (authHeader && typeof authHeader === "string") { + const parts = authHeader.split(" "); + if (parts.length === 2 && parts[0] === "Bearer") { + return parts[1] || null; + } } - return parts[1]; + return readCookie(req, ACCESS_TOKEN_COOKIE_NAME); }; const verifyToken = (token: string): JwtPayload | null => { diff --git a/backend/src/server/socket.ts b/backend/src/server/socket.ts index 42f2d87..402d1a0 100644 --- a/backend/src/server/socket.ts +++ b/backend/src/server/socket.ts @@ -2,6 +2,7 @@ import jwt from "jsonwebtoken"; import { Server } from "socket.io"; import { PrismaClient } from "../generated/client"; import { AuthModeService } from "../auth/authMode"; +import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies"; interface User { id: string; @@ -87,7 +88,13 @@ export const registerSocketHandlers = ({ io.use(async (socket, next) => { try { - const token = socket.handshake.auth?.token as string | undefined; + const tokenFromAuth = socket.handshake.auth?.token as string | undefined; + const tokenFromCookie = (() => { + const cookies = parseCookieHeader(socket.handshake.headers.cookie); + const value = cookies[ACCESS_TOKEN_COOKIE_NAME]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; + })(); + const token = tokenFromAuth || tokenFromCookie; const userId = await getSocketAuthUserId(token); if (!userId) { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6d12719..91d1c19 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,7 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production + - TRUST_PROXY=1 # Optional for single-instance deployments: # if unset, backend auto-generates and persists one in the volume. # Recommended to set explicitly for portability and multi-instance setups. diff --git a/docker-compose.yml b/docker-compose.yml index 01daa80..baa76e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production + - TRUST_PROXY=1 # Optional for single-instance deployments: # if unset, backend auto-generates and persists one in the volume. # Recommended to set explicitly for portability and multi-instance setups. diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 720a280..4e9a81a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -16,9 +16,7 @@ export const isAxiosError = axios.isAxiosError; // Export api instance for direct use export { api as default }; -// JWT Token Management -const TOKEN_KEY = 'excalidash-access-token'; -const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; +// Auth state persisted in local storage should remain non-sensitive. const USER_KEY = 'excalidash-user'; const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled"; const AUTH_STATUS_TTL_MS = 5000; @@ -33,11 +31,6 @@ type RetriableRequestConfig = { let authEnabledProbeCache: { value: boolean; fetchedAt: number } | null = null; -const getAuthToken = (): string | null => { - if (typeof window === 'undefined') return null; - return localStorage.getItem(TOKEN_KEY); -}; - // CSRF Token Management let csrfToken: string | null = null; let csrfHeaderName: string = "x-csrf-token"; @@ -96,25 +89,32 @@ export const authStatus = async (): Promise => { return response.data; }; -export const authMe = async (accessToken: string): Promise<{ user: AuthUser }> => { +export const authMe = async (): Promise<{ user: AuthUser }> => { const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, { - headers: { Authorization: `Bearer ${accessToken}` }, withCredentials: true, }); return response.data; }; export const authRefresh = async ( - refreshToken: string + refreshToken?: string ): Promise<{ accessToken: string; refreshToken?: string }> => { + const body = + typeof refreshToken === "string" && refreshToken.trim().length > 0 + ? { refreshToken } + : {}; const response = await axios.post<{ accessToken: string; refreshToken?: string }>( `${API_URL}/auth/refresh`, - { refreshToken }, + body, { withCredentials: true } ); return response.data; }; +export const authLogout = async (): Promise => { + await api.post("/auth/logout"); +}; + export const authLogin = async ( email: string, password: string @@ -152,8 +152,6 @@ export const authPasswordResetConfirm = async ( }; const clearStoredAuth = () => { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(USER_KEY); }; @@ -205,23 +203,13 @@ let refreshPromise: Promise | null = null; const refreshAccessToken = async (): Promise => { if (!refreshPromise) { refreshPromise = (async () => { - const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - if (!refreshToken) { - throw new Error("Missing refresh token"); - } - - const refreshResponse = await authRefresh(refreshToken); + const refreshResponse = await authRefresh(); const nextAccessToken = String(refreshResponse.accessToken || ""); if (!nextAccessToken) { throw new Error("Missing access token in refresh response"); } - localStorage.setItem(TOKEN_KEY, nextAccessToken); - if (refreshResponse.refreshToken) { - localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken); - } - return nextAccessToken; })().finally(() => { refreshPromise = null; @@ -245,14 +233,6 @@ api.interceptors.request.use( const isPublicAuthEndpoint = config.url && publicAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint)); - // Add JWT token to all requests except public auth endpoints - if (!isPublicAuthEndpoint) { - const token = getAuthToken(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - } - // Only add CSRF token for state-changing methods (except public auth endpoints) const method = config.method?.toUpperCase(); if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) { @@ -293,7 +273,6 @@ api.interceptors.response.use( const originalRequest = (error.config || {}) as RetriableRequestConfig; const url = String(originalRequest.url || ""); const isAuthRoute = url.includes('/auth/'); - const hasRefreshToken = Boolean(localStorage.getItem(REFRESH_TOKEN_KEY)); const authEnabled = !isAuthRoute ? await getAuthEnabledStatus() : true; if (!isAuthRoute && authEnabled === false) { @@ -304,12 +283,10 @@ api.interceptors.response.use( return Promise.reject(error); } - if (!isAuthRoute && hasRefreshToken && !originalRequest._retry) { + if (!isAuthRoute && !originalRequest._retry) { try { originalRequest._retry = true; - const nextAccessToken = await refreshAccessToken(); - originalRequest.headers = originalRequest.headers || {}; - originalRequest.headers.Authorization = `Bearer ${nextAccessToken}`; + await refreshAccessToken(); return api(originalRequest as any); } catch { clearStoredAuth(); diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx index df8082d..e814bd1 100644 --- a/frontend/src/context/AuthContext.test.tsx +++ b/frontend/src/context/AuthContext.test.tsx @@ -34,7 +34,7 @@ describe("AuthProvider", () => { }, }); - vi.spyOn(axios, "get").mockRejectedValueOnce(new Error("network down")); + vi.spyOn(axios, "get").mockRejectedValue(new Error("network down")); render( @@ -52,8 +52,6 @@ describe("AuthProvider", () => { it("clears stored auth state when backend reports auth disabled", async () => { const storage = new Map([ - ["excalidash-access-token", "token"], - ["excalidash-refresh-token", "refresh"], ["excalidash-user", JSON.stringify({ id: "u1" })], ]); Object.defineProperty(window, "localStorage", { @@ -83,16 +81,12 @@ describe("AuthProvider", () => { expect(screen.getByTestId("loading").textContent).toBe("false"); }); expect(screen.getByTestId("auth-enabled").textContent).toBe("false"); - expect(storage.get("excalidash-access-token")).toBeUndefined(); - expect(storage.get("excalidash-refresh-token")).toBeUndefined(); expect(storage.get("excalidash-user")).toBeUndefined(); }); it("uses cached auth-disabled mode when /auth/status is temporarily unavailable", async () => { const storage = new Map([ ["excalidash-auth-enabled", "false"], - ["excalidash-access-token", "token"], - ["excalidash-refresh-token", "refresh"], ["excalidash-user", JSON.stringify({ id: "u1" })], ]); Object.defineProperty(window, "localStorage", { @@ -108,7 +102,7 @@ describe("AuthProvider", () => { }, }); - vi.spyOn(axios, "get").mockRejectedValueOnce(new Error("network down")); + vi.spyOn(axios, "get").mockRejectedValue(new Error("network down")); render( @@ -122,8 +116,6 @@ describe("AuthProvider", () => { expect(screen.getByTestId("loading").textContent).toBe("false"); }); expect(screen.getByTestId("auth-enabled").textContent).toBe("false"); - expect(storage.get("excalidash-access-token")).toBeUndefined(); - expect(storage.get("excalidash-refresh-token")).toBeUndefined(); expect(storage.get("excalidash-user")).toBeUndefined(); }); }); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index a484642..d75491a 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -5,6 +5,7 @@ import { authStatus, authMe, authRefresh, + authLogout, authLogin, authRegister, isAxiosError, @@ -32,8 +33,6 @@ interface AuthContextType { const AuthContext = createContext(undefined); -const TOKEN_KEY = 'excalidash-access-token'; -const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; const USER_KEY = 'excalidash-user'; const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled"; @@ -60,8 +59,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired)); if (!enabled) { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(USER_KEY); setUser(null); return; @@ -71,8 +68,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (cachedAuthEnabled === "false") { setAuthEnabled(false); setBootstrapRequired(false); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(USER_KEY); setUser(null); return; @@ -82,44 +77,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } const storedUser = localStorage.getItem(USER_KEY); - const storedToken = localStorage.getItem(TOKEN_KEY); - - if (storedUser && storedToken) { + if (storedUser) { const userData = JSON.parse(storedUser); setUser(userData); + } + try { + const response = await authMe(); + setUser(response.user); + localStorage.setItem(USER_KEY, JSON.stringify(response.user)); + } catch { try { - const response = await authMe(storedToken); - setUser(response.user); + await authRefresh(); + const userResponse = await authMe(); + setUser(userResponse.user); + localStorage.setItem(USER_KEY, JSON.stringify(userResponse.user)); } catch { - const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - if (refreshToken) { - try { - const refreshResponse = await authRefresh(refreshToken); - localStorage.setItem(TOKEN_KEY, refreshResponse.accessToken); - if (refreshResponse.refreshToken) { - localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken); - } - const userResponse = await authMe(refreshResponse.accessToken); - setUser(userResponse.user); - } catch { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(USER_KEY); - setUser(null); - } - } else { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(USER_KEY); - setUser(null); - } + localStorage.removeItem(USER_KEY); + setUser(null); } } } catch (error) { console.error('Failed to load user:', error); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(USER_KEY); setUser(null); } finally { @@ -137,10 +116,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } const response = await authLogin(email, password); - const { user: userData, accessToken, refreshToken } = response; + const { user: userData } = response; - localStorage.setItem(TOKEN_KEY, accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(userData)); setUser(userData); @@ -166,10 +143,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } const response = await authRegister(email, password, name); - const { user: userData, accessToken, refreshToken } = response; + const { user: userData } = response; - localStorage.setItem(TOKEN_KEY, accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(userData)); setUser(userData); @@ -189,8 +164,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }; const logout = () => { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); + void authLogout().catch(() => undefined); localStorage.removeItem(USER_KEY); setUser(null); setTimeout(() => { diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 72f0bd3..dfb7f4c 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -7,11 +7,9 @@ import * as api from '../api'; import type { Collection } from '../types'; import { Shield, UserPlus, RefreshCw, UserCog, LogIn, XCircle, Settings as SettingsIcon, KeyRound } from 'lucide-react'; import { - ACCESS_TOKEN_KEY, IMPERSONATION_KEY, type ImpersonationState, readImpersonationState, - REFRESH_TOKEN_KEY, stopImpersonation as restoreImpersonation, USER_KEY, } from '../utils/impersonation'; @@ -290,11 +288,9 @@ export const Admin: React.FC = () => { return; } - const originalAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY); - const originalRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); const originalUser = localStorage.getItem(USER_KEY); - if (!originalAccessToken || !originalRefreshToken || !originalUser) { - setError('Missing current session tokens.'); + if (!originalUser) { + setError('Missing current session user state.'); return; } @@ -307,8 +303,6 @@ export const Admin: React.FC = () => { const state: ImpersonationState = { original: { - accessToken: originalAccessToken, - refreshToken: originalRefreshToken, user: JSON.parse(originalUser), }, impersonator: { @@ -325,8 +319,6 @@ export const Admin: React.FC = () => { }; localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state)); - localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); window.location.href = '/'; @@ -339,9 +331,26 @@ export const Admin: React.FC = () => { } }; - const stopImpersonation = () => { - if (!restoreImpersonation()) return; - window.location.href = '/admin'; + const stopImpersonation = async () => { + if (!readImpersonationState()) return; + + try { + const response = await api.api.post<{ + user?: { id: string; email: string; name: string }; + }>('/auth/stop-impersonation'); + + restoreImpersonation(); + if (response.data?.user) { + localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); + } + window.location.href = '/admin'; + } catch (err: unknown) { + let message = 'Failed to stop impersonation'; + if (api.isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setError(message); + } }; if (authEnabled === null) { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a38a840..e9fb6a5 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -962,7 +962,7 @@ export const Dashboard: React.FC = () => { ) : (
{sortedDrawings.length === 0 ? (
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 30a4f04..d3183b7 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -257,11 +257,10 @@ export const Editor: React.FC = () => { ? window.location.origin : (import.meta.env.VITE_API_URL || 'http://localhost:8000'); - const authToken = localStorage.getItem('excalidash-access-token'); const socket = io(socketUrl, { path: '/socket.io', transports: ['websocket', 'polling'], - auth: authToken ? { token: authToken } : {}, + withCredentials: true, }); socketRef.current = socket; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1d47441..d94fc80 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,7 +3,7 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { Logo } from '../components/Logo'; import * as api from '../api'; -import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY } from '../utils/impersonation'; +import { USER_KEY } from '../utils/impersonation'; export const Login: React.FC = () => { const [email, setEmail] = useState(''); @@ -81,8 +81,6 @@ export const Login: React.FC = () => { refreshToken: string; }>('/auth/must-reset-password', { newPassword }); - localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); window.location.href = '/'; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 1a2f5ae..cfc9dd6 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext'; import * as api from '../api'; import type { Collection } from '../types'; import { User, Lock, Save, X, Shield } from 'lucide-react'; -import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY } from '../utils/impersonation'; +import { USER_KEY } from '../utils/impersonation'; export const Profile: React.FC = () => { const { user: authUser, logout, authEnabled } = useAuth(); @@ -234,8 +234,6 @@ export const Profile: React.FC = () => { currentPassword: emailCurrentPassword, }); - localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); setSuccess('Email updated successfully'); diff --git a/frontend/src/utils/impersonation.ts b/frontend/src/utils/impersonation.ts index 56fa8a3..d53e588 100644 --- a/frontend/src/utils/impersonation.ts +++ b/frontend/src/utils/impersonation.ts @@ -1,12 +1,8 @@ -export const ACCESS_TOKEN_KEY = 'excalidash-access-token'; -export const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; export const USER_KEY = 'excalidash-user'; export const IMPERSONATION_KEY = 'excalidash-impersonation'; export type ImpersonationState = { original: { - accessToken: string; - refreshToken: string; user: unknown; }; impersonator: { @@ -28,7 +24,7 @@ export const readImpersonationState = (): ImpersonationState | null => { const raw = localStorage.getItem(IMPERSONATION_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as ImpersonationState; - if (!parsed?.original?.accessToken || !parsed?.original?.refreshToken) return null; + if (!parsed?.original?.user) return null; return parsed; } catch { return null; @@ -38,10 +34,7 @@ export const readImpersonationState = (): ImpersonationState | null => { export const stopImpersonation = (): boolean => { const state = readImpersonationState(); if (!state) return false; - localStorage.setItem(ACCESS_TOKEN_KEY, state.original.accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, state.original.refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(state.original.user)); localStorage.removeItem(IMPERSONATION_KEY); return true; }; -