fix csrf token hardset, remove cookie from localstorage
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string> => {
|
||||
if (!cookieHeader) return {};
|
||||
|
||||
const cookies: Record<string, string> = {};
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+11
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -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"
|
||||
// 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.parseInt(trustProxyConfig, 10) || 1;
|
||||
: Number.isFinite(parsedProxyHops) && parsedProxyHops > 0
|
||||
? parsedProxyHops
|
||||
: false;
|
||||
app.set("trust proxy", trustProxyValue);
|
||||
|
||||
if (trustProxyValue === true) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
if (authHeader && typeof authHeader === "string") {
|
||||
const parts = authHeader.split(" ");
|
||||
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
||||
return null;
|
||||
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 => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-38
@@ -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<AuthStatusResponse> => {
|
||||
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<void> => {
|
||||
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<string> | null = null;
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
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();
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
@@ -52,8 +52,6 @@ describe("AuthProvider", () => {
|
||||
|
||||
it("clears stored auth state when backend reports auth disabled", async () => {
|
||||
const storage = new Map<string, string>([
|
||||
["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<string, string>([
|
||||
["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(
|
||||
<MemoryRouter>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
authStatus,
|
||||
authMe,
|
||||
authRefresh,
|
||||
authLogout,
|
||||
authLogin,
|
||||
authRegister,
|
||||
isAxiosError,
|
||||
@@ -32,8 +33,6 @@ interface AuthContextType {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(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(storedToken);
|
||||
const response = await authMe();
|
||||
setUser(response.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.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);
|
||||
await authRefresh();
|
||||
const userResponse = await authMe();
|
||||
setUser(userResponse.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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(() => {
|
||||
|
||||
@@ -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;
|
||||
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) {
|
||||
|
||||
@@ -962,7 +962,7 @@ export const Dashboard: React.FC = () => {
|
||||
) : (
|
||||
<div
|
||||
className={clsx("grid gap-3 sm:gap-4 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}
|
||||
>
|
||||
{sortedDrawings.length === 0 ? (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-16 sm:py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = '/';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user