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:
|
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.
|
- `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.
|
- `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
|
```yaml
|
||||||
@@ -131,6 +132,8 @@ backend:
|
|||||||
environment:
|
environment:
|
||||||
# Single URL
|
# Single URL
|
||||||
- FRONTEND_URL=https://excalidash.example.com
|
- FRONTEND_URL=https://excalidash.example.com
|
||||||
|
# Trust exactly one reverse-proxy hop
|
||||||
|
- TRUST_PROXY=1
|
||||||
# Or multiple URLs (comma-separated) for local + network access
|
# 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_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ PORT=8000
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
FRONTEND_URL=http://localhost:6767
|
||||||
|
TRUST_PROXY=1
|
||||||
JWT_SECRET=change-this-secret-in-production-min-32-chars
|
JWT_SECRET=change-this-secret-in-production-min-32-chars
|
||||||
|
|
||||||
# Optional Feature Flags (all default to false for backward compatibility)
|
# Optional Feature Flags (all default to false for backward compatibility)
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ import {
|
|||||||
type AuthModeService,
|
type AuthModeService,
|
||||||
} from "./auth/authMode";
|
} from "./auth/authMode";
|
||||||
import { getCsrfValidationClientIds } from "./security/csrfClient";
|
import { getCsrfValidationClientIds } from "./security/csrfClient";
|
||||||
|
import {
|
||||||
|
clearAuthCookies,
|
||||||
|
readCookie,
|
||||||
|
REFRESH_TOKEN_COOKIE_NAME,
|
||||||
|
setAccessTokenCookie,
|
||||||
|
setAuthCookies,
|
||||||
|
} from "./auth/cookies";
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -380,6 +387,10 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
|
|||||||
bootstrapUserId: BOOTSTRAP_USER_ID,
|
bootstrapUserId: BOOTSTRAP_USER_ID,
|
||||||
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
clearAuthEnabledCache: authModeService.clearAuthEnabledCache,
|
clearAuthEnabledCache: authModeService.clearAuthEnabledCache,
|
||||||
|
setAuthCookies,
|
||||||
|
setAccessTokenCookie,
|
||||||
|
clearAuthCookies,
|
||||||
|
readRefreshTokenFromRequest: (req) => readCookie(req, REFRESH_TOKEN_COOKIE_NAME),
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAdminRoutes({
|
registerAdminRoutes({
|
||||||
@@ -401,6 +412,8 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
|
|||||||
getRefreshTokenExpiresAt,
|
getRefreshTokenExpiresAt,
|
||||||
config,
|
config,
|
||||||
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
setAuthCookies,
|
||||||
|
requireCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAccountRoutes({
|
registerAccountRoutes({
|
||||||
@@ -414,6 +427,8 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
|
|||||||
config,
|
config,
|
||||||
generateTokens,
|
generateTokens,
|
||||||
getRefreshTokenExpiresAt,
|
getRefreshTokenExpiresAt,
|
||||||
|
setAuthCookies,
|
||||||
|
requireCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ type RegisterAccountRoutesDeps = {
|
|||||||
options?: { impersonatorId?: string }
|
options?: { impersonatorId?: string }
|
||||||
) => { accessToken: string; refreshToken: string };
|
) => { accessToken: string; refreshToken: string };
|
||||||
getRefreshTokenExpiresAt: () => Date;
|
getRefreshTokenExpiresAt: () => Date;
|
||||||
|
setAuthCookies: (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
tokens: { accessToken: string; refreshToken: string }
|
||||||
|
) => void;
|
||||||
|
requireCsrf: (req: Request, res: Response) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||||
@@ -48,6 +54,8 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
config,
|
config,
|
||||||
generateTokens,
|
generateTokens,
|
||||||
getRefreshTokenExpiresAt,
|
getRefreshTokenExpiresAt,
|
||||||
|
setAuthCookies,
|
||||||
|
requireCsrf,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => {
|
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) => {
|
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Unauthorized",
|
error: "Unauthorized",
|
||||||
@@ -261,6 +270,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
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);
|
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
|
||||||
|
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
try {
|
try {
|
||||||
@@ -387,6 +398,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
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) => {
|
router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
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);
|
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
|
||||||
|
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ type RegisterAdminRoutesDeps = {
|
|||||||
enableRefreshTokenRotation: boolean;
|
enableRefreshTokenRotation: boolean;
|
||||||
};
|
};
|
||||||
defaultSystemConfigId: string;
|
defaultSystemConfigId: string;
|
||||||
|
setAuthCookies: (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
tokens: { accessToken: string; refreshToken: string }
|
||||||
|
) => void;
|
||||||
|
requireCsrf: (req: Request, res: Response) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||||
@@ -86,11 +92,14 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
|||||||
getRefreshTokenExpiresAt,
|
getRefreshTokenExpiresAt,
|
||||||
config,
|
config,
|
||||||
defaultSystemConfigId,
|
defaultSystemConfigId,
|
||||||
|
setAuthCookies,
|
||||||
|
requireCsrf,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
|
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const parsed = registrationToggleSchema.safeParse(req.body);
|
const parsed = registrationToggleSchema.safeParse(req.body);
|
||||||
@@ -117,6 +126,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
|||||||
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
|
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const parsed = adminRoleUpdateSchema.safeParse(req.body);
|
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) => {
|
router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const parsed = loginRateLimitUpdateSchema.safeParse(req.body);
|
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) => {
|
router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const parsed = loginRateLimitResetSchema.safeParse(req.body);
|
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) => {
|
router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const parsed = adminCreateUserSchema.safeParse(req.body);
|
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) => {
|
router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const userId = String(req.params.id || "").trim();
|
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) => {
|
router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
if (req.user.impersonatorId) {
|
if (req.user.impersonatorId) {
|
||||||
@@ -583,6 +598,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
|||||||
router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
const parsed = impersonateSchema.safeParse(req.body);
|
const parsed = impersonateSchema.safeParse(req.body);
|
||||||
@@ -606,6 +622,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
|||||||
const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
|
const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
|
||||||
impersonatorId: req.user.id,
|
impersonatorId: req.user.id,
|
||||||
});
|
});
|
||||||
|
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||||
|
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
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;
|
bootstrapUserId: string;
|
||||||
defaultSystemConfigId: string;
|
defaultSystemConfigId: string;
|
||||||
clearAuthEnabledCache: () => void;
|
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 {
|
class HttpError extends Error {
|
||||||
@@ -88,6 +96,10 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
bootstrapUserId,
|
bootstrapUserId,
|
||||||
defaultSystemConfigId,
|
defaultSystemConfigId,
|
||||||
clearAuthEnabledCache,
|
clearAuthEnabledCache,
|
||||||
|
setAuthCookies,
|
||||||
|
setAccessTokenCookie,
|
||||||
|
clearAuthCookies,
|
||||||
|
readRefreshTokenFromRequest,
|
||||||
} = deps;
|
} = deps;
|
||||||
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
||||||
|
|
||||||
@@ -158,6 +170,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
||||||
|
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||||
|
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
@@ -257,6 +270,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
||||||
|
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||||
|
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
@@ -375,6 +389,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
||||||
|
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||||
|
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
@@ -431,7 +446,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
router.post("/refresh", async (req: Request, res: Response) => {
|
router.post("/refresh", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
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") {
|
if (!oldRefreshToken || typeof oldRefreshToken !== "string") {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -513,6 +530,10 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setAuthCookies(req, res, {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
});
|
||||||
return res.json({
|
return res.json({
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken: newRefreshToken,
|
refreshToken: newRefreshToken,
|
||||||
@@ -555,6 +576,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
signOptions
|
signOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setAccessTokenCookie(req, res, accessToken);
|
||||||
res.json({ accessToken });
|
res.json({ accessToken });
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({
|
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) => {
|
router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!(await ensureAuthEnabled(res))) return;
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { z } from "zod";
|
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({
|
export const registerSchema = z.object({
|
||||||
username: z.string().trim().min(3).max(50).optional(),
|
username: z.string().trim().min(3).max(50).optional(),
|
||||||
email: z.string().email().toLowerCase().trim(),
|
email: z.string().email().toLowerCase().trim(),
|
||||||
password: z.string().min(8).max(100),
|
password: passwordSchema,
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +50,7 @@ export const authEnabledToggleSchema = z.object({
|
|||||||
export const adminCreateUserSchema = z.object({
|
export const adminCreateUserSchema = z.object({
|
||||||
username: z.string().trim().min(3).max(50).optional(),
|
username: z.string().trim().min(3).max(50).optional(),
|
||||||
email: z.string().email().toLowerCase().trim(),
|
email: z.string().email().toLowerCase().trim(),
|
||||||
password: z.string().min(8).max(100),
|
password: passwordSchema,
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
role: z.enum(["ADMIN", "USER"]).optional(),
|
role: z.enum(["ADMIN", "USER"]).optional(),
|
||||||
mustResetPassword: z.boolean().optional(),
|
mustResetPassword: z.boolean().optional(),
|
||||||
@@ -74,7 +90,7 @@ export const passwordResetRequestSchema = z.object({
|
|||||||
|
|
||||||
export const passwordResetConfirmSchema = z.object({
|
export const passwordResetConfirmSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
password: z.string().min(8).max(100),
|
password: passwordSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
@@ -88,9 +104,9 @@ export const updateEmailSchema = z.object({
|
|||||||
|
|
||||||
export const changePasswordSchema = z.object({
|
export const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string(),
|
currentPassword: z.string(),
|
||||||
newPassword: z.string().min(8).max(100),
|
newPassword: passwordSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mustResetPasswordSchema = z.object({
|
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
|
// Validate JWT_SECRET strength in production
|
||||||
if (config.nodeEnv === "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) {
|
if (config.jwtSecret.length < 32) {
|
||||||
throw new Error("JWT_SECRET must be at least 32 characters long in production");
|
throw new Error("JWT_SECRET must be at least 32 characters long in production");
|
||||||
}
|
}
|
||||||
if (config.jwtSecret === "your-secret-key-change-in-production") {
|
if (
|
||||||
throw new Error("JWT_SECRET must be changed from default value in production");
|
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();
|
const app = express();
|
||||||
|
|
||||||
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx.
|
// Trust proxy headers (X-Forwarded-For, X-Real-IP) only when explicitly configured.
|
||||||
// Default to a single trusted proxy hop unless TRUST_PROXY is explicitly configured.
|
// Safe default is disabled to avoid spoofed client IPs when running without a trusted proxy.
|
||||||
// Set TRUST_PROXY=true only when you fully trust all upstream proxy hops.
|
// Set TRUST_PROXY=1 (or a specific hop count) when deploying behind reverse proxies.
|
||||||
const trustProxyConfig = (process.env.TRUST_PROXY ?? "1").trim();
|
const trustProxyConfig = (process.env.TRUST_PROXY ?? "false").trim();
|
||||||
const trustProxyValue = trustProxyConfig === "true"
|
const parsedProxyHops = Number.parseInt(trustProxyConfig, 10);
|
||||||
|
const trustProxyValue =
|
||||||
|
trustProxyConfig === "true"
|
||||||
? true
|
? true
|
||||||
: trustProxyConfig === "false"
|
: trustProxyConfig === "false"
|
||||||
? false
|
? false
|
||||||
: Number.parseInt(trustProxyConfig, 10) || 1;
|
: Number.isFinite(parsedProxyHops) && parsedProxyHops > 0
|
||||||
|
? parsedProxyHops
|
||||||
|
: false;
|
||||||
app.set("trust proxy", trustProxyValue);
|
app.set("trust proxy", trustProxyValue);
|
||||||
|
|
||||||
if (trustProxyValue === true) {
|
if (trustProxyValue === true) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { config } from "../config";
|
|||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
import { prisma as defaultPrisma } from "../db/prisma";
|
import { prisma as defaultPrisma } from "../db/prisma";
|
||||||
import { createAuthModeService, type AuthModeService } from "../auth/authMode";
|
import { createAuthModeService, type AuthModeService } from "../auth/authMode";
|
||||||
|
import { ACCESS_TOKEN_COOKIE_NAME, readCookie } from "../auth/cookies";
|
||||||
|
|
||||||
// Extend Express Request type to include user
|
// Extend Express Request type to include user
|
||||||
declare global {
|
declare global {
|
||||||
@@ -46,14 +47,14 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
|
|||||||
|
|
||||||
const extractToken = (req: Request): string | null => {
|
const extractToken = (req: Request): string | null => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || typeof authHeader !== "string") return null;
|
if (authHeader && typeof authHeader === "string") {
|
||||||
|
|
||||||
const parts = authHeader.split(" ");
|
const parts = authHeader.split(" ");
|
||||||
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
if (parts.length === 2 && parts[0] === "Bearer") {
|
||||||
return null;
|
return parts[1] || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts[1];
|
return readCookie(req, ACCESS_TOKEN_COOKIE_NAME);
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyToken = (token: string): JwtPayload | null => {
|
const verifyToken = (token: string): JwtPayload | null => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import jwt from "jsonwebtoken";
|
|||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
import { AuthModeService } from "../auth/authMode";
|
import { AuthModeService } from "../auth/authMode";
|
||||||
|
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,7 +88,13 @@ export const registerSocketHandlers = ({
|
|||||||
|
|
||||||
io.use(async (socket, next) => {
|
io.use(async (socket, next) => {
|
||||||
try {
|
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);
|
const userId = await getSocketAuthUserId(token);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/dev.db
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- TRUST_PROXY=1
|
||||||
# Optional for single-instance deployments:
|
# Optional for single-instance deployments:
|
||||||
# if unset, backend auto-generates and persists one in the volume.
|
# if unset, backend auto-generates and persists one in the volume.
|
||||||
# Recommended to set explicitly for portability and multi-instance setups.
|
# Recommended to set explicitly for portability and multi-instance setups.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/dev.db
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- TRUST_PROXY=1
|
||||||
# Optional for single-instance deployments:
|
# Optional for single-instance deployments:
|
||||||
# if unset, backend auto-generates and persists one in the volume.
|
# if unset, backend auto-generates and persists one in the volume.
|
||||||
# Recommended to set explicitly for portability and multi-instance setups.
|
# 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 instance for direct use
|
||||||
export { api as default };
|
export { api as default };
|
||||||
|
|
||||||
// JWT Token Management
|
// Auth state persisted in local storage should remain non-sensitive.
|
||||||
const TOKEN_KEY = 'excalidash-access-token';
|
|
||||||
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
|
|
||||||
const USER_KEY = 'excalidash-user';
|
const USER_KEY = 'excalidash-user';
|
||||||
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
|
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
|
||||||
const AUTH_STATUS_TTL_MS = 5000;
|
const AUTH_STATUS_TTL_MS = 5000;
|
||||||
@@ -33,11 +31,6 @@ type RetriableRequestConfig = {
|
|||||||
|
|
||||||
let authEnabledProbeCache: { value: boolean; fetchedAt: number } | null = null;
|
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
|
// CSRF Token Management
|
||||||
let csrfToken: string | null = null;
|
let csrfToken: string | null = null;
|
||||||
let csrfHeaderName: string = "x-csrf-token";
|
let csrfHeaderName: string = "x-csrf-token";
|
||||||
@@ -96,25 +89,32 @@ export const authStatus = async (): Promise<AuthStatusResponse> => {
|
|||||||
return response.data;
|
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`, {
|
const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authRefresh = async (
|
export const authRefresh = async (
|
||||||
refreshToken: string
|
refreshToken?: string
|
||||||
): Promise<{ accessToken: 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 }>(
|
const response = await axios.post<{ accessToken: string; refreshToken?: string }>(
|
||||||
`${API_URL}/auth/refresh`,
|
`${API_URL}/auth/refresh`,
|
||||||
{ refreshToken },
|
body,
|
||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const authLogout = async (): Promise<void> => {
|
||||||
|
await api.post("/auth/logout");
|
||||||
|
};
|
||||||
|
|
||||||
export const authLogin = async (
|
export const authLogin = async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
@@ -152,8 +152,6 @@ export const authPasswordResetConfirm = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearStoredAuth = () => {
|
const clearStoredAuth = () => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,23 +203,13 @@ let refreshPromise: Promise<string> | null = null;
|
|||||||
const refreshAccessToken = async (): Promise<string> => {
|
const refreshAccessToken = async (): Promise<string> => {
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
const refreshResponse = await authRefresh();
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error("Missing refresh token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshResponse = await authRefresh(refreshToken);
|
|
||||||
|
|
||||||
const nextAccessToken = String(refreshResponse.accessToken || "");
|
const nextAccessToken = String(refreshResponse.accessToken || "");
|
||||||
if (!nextAccessToken) {
|
if (!nextAccessToken) {
|
||||||
throw new Error("Missing access token in refresh response");
|
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;
|
return nextAccessToken;
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
@@ -245,14 +233,6 @@ api.interceptors.request.use(
|
|||||||
|
|
||||||
const isPublicAuthEndpoint = config.url && publicAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint));
|
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)
|
// Only add CSRF token for state-changing methods (except public auth endpoints)
|
||||||
const method = config.method?.toUpperCase();
|
const method = config.method?.toUpperCase();
|
||||||
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) {
|
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) {
|
||||||
@@ -293,7 +273,6 @@ api.interceptors.response.use(
|
|||||||
const originalRequest = (error.config || {}) as RetriableRequestConfig;
|
const originalRequest = (error.config || {}) as RetriableRequestConfig;
|
||||||
const url = String(originalRequest.url || "");
|
const url = String(originalRequest.url || "");
|
||||||
const isAuthRoute = url.includes('/auth/');
|
const isAuthRoute = url.includes('/auth/');
|
||||||
const hasRefreshToken = Boolean(localStorage.getItem(REFRESH_TOKEN_KEY));
|
|
||||||
const authEnabled = !isAuthRoute ? await getAuthEnabledStatus() : true;
|
const authEnabled = !isAuthRoute ? await getAuthEnabledStatus() : true;
|
||||||
|
|
||||||
if (!isAuthRoute && authEnabled === false) {
|
if (!isAuthRoute && authEnabled === false) {
|
||||||
@@ -304,12 +283,10 @@ api.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthRoute && hasRefreshToken && !originalRequest._retry) {
|
if (!isAuthRoute && !originalRequest._retry) {
|
||||||
try {
|
try {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
const nextAccessToken = await refreshAccessToken();
|
await refreshAccessToken();
|
||||||
originalRequest.headers = originalRequest.headers || {};
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${nextAccessToken}`;
|
|
||||||
return api(originalRequest as any);
|
return api(originalRequest as any);
|
||||||
} catch {
|
} catch {
|
||||||
clearStoredAuth();
|
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(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -52,8 +52,6 @@ describe("AuthProvider", () => {
|
|||||||
|
|
||||||
it("clears stored auth state when backend reports auth disabled", async () => {
|
it("clears stored auth state when backend reports auth disabled", async () => {
|
||||||
const storage = new Map<string, string>([
|
const storage = new Map<string, string>([
|
||||||
["excalidash-access-token", "token"],
|
|
||||||
["excalidash-refresh-token", "refresh"],
|
|
||||||
["excalidash-user", JSON.stringify({ id: "u1" })],
|
["excalidash-user", JSON.stringify({ id: "u1" })],
|
||||||
]);
|
]);
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
@@ -83,16 +81,12 @@ describe("AuthProvider", () => {
|
|||||||
expect(screen.getByTestId("loading").textContent).toBe("false");
|
expect(screen.getByTestId("loading").textContent).toBe("false");
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("auth-enabled").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();
|
expect(storage.get("excalidash-user")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses cached auth-disabled mode when /auth/status is temporarily unavailable", async () => {
|
it("uses cached auth-disabled mode when /auth/status is temporarily unavailable", async () => {
|
||||||
const storage = new Map<string, string>([
|
const storage = new Map<string, string>([
|
||||||
["excalidash-auth-enabled", "false"],
|
["excalidash-auth-enabled", "false"],
|
||||||
["excalidash-access-token", "token"],
|
|
||||||
["excalidash-refresh-token", "refresh"],
|
|
||||||
["excalidash-user", JSON.stringify({ id: "u1" })],
|
["excalidash-user", JSON.stringify({ id: "u1" })],
|
||||||
]);
|
]);
|
||||||
Object.defineProperty(window, "localStorage", {
|
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(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -122,8 +116,6 @@ describe("AuthProvider", () => {
|
|||||||
expect(screen.getByTestId("loading").textContent).toBe("false");
|
expect(screen.getByTestId("loading").textContent).toBe("false");
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("auth-enabled").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();
|
expect(storage.get("excalidash-user")).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
authStatus,
|
authStatus,
|
||||||
authMe,
|
authMe,
|
||||||
authRefresh,
|
authRefresh,
|
||||||
|
authLogout,
|
||||||
authLogin,
|
authLogin,
|
||||||
authRegister,
|
authRegister,
|
||||||
isAxiosError,
|
isAxiosError,
|
||||||
@@ -32,8 +33,6 @@ interface AuthContextType {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
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 USER_KEY = 'excalidash-user';
|
||||||
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
|
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
|
||||||
|
|
||||||
@@ -60,8 +59,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
|
setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
return;
|
return;
|
||||||
@@ -71,8 +68,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
if (cachedAuthEnabled === "false") {
|
if (cachedAuthEnabled === "false") {
|
||||||
setAuthEnabled(false);
|
setAuthEnabled(false);
|
||||||
setBootstrapRequired(false);
|
setBootstrapRequired(false);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
return;
|
return;
|
||||||
@@ -82,44 +77,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storedUser = localStorage.getItem(USER_KEY);
|
const storedUser = localStorage.getItem(USER_KEY);
|
||||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
if (storedUser) {
|
||||||
|
|
||||||
if (storedUser && storedToken) {
|
|
||||||
const userData = JSON.parse(storedUser);
|
const userData = JSON.parse(storedUser);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authMe(storedToken);
|
const response = await authMe();
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
||||||
} catch {
|
} catch {
|
||||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
||||||
if (refreshToken) {
|
|
||||||
try {
|
try {
|
||||||
const refreshResponse = await authRefresh(refreshToken);
|
await authRefresh();
|
||||||
localStorage.setItem(TOKEN_KEY, refreshResponse.accessToken);
|
const userResponse = await authMe();
|
||||||
if (refreshResponse.refreshToken) {
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken);
|
|
||||||
}
|
|
||||||
const userResponse = await authMe(refreshResponse.accessToken);
|
|
||||||
setUser(userResponse.user);
|
setUser(userResponse.user);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(userResponse.user));
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user:', error);
|
console.error('Failed to load user:', error);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,10 +116,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
const response = await authLogin(email, password);
|
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));
|
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
@@ -166,10 +143,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
const response = await authRegister(email, password, name);
|
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));
|
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
@@ -189,8 +164,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
void authLogout().catch(() => undefined);
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import * as api from '../api';
|
|||||||
import type { Collection } from '../types';
|
import type { Collection } from '../types';
|
||||||
import { Shield, UserPlus, RefreshCw, UserCog, LogIn, XCircle, Settings as SettingsIcon, KeyRound } from 'lucide-react';
|
import { Shield, UserPlus, RefreshCw, UserCog, LogIn, XCircle, Settings as SettingsIcon, KeyRound } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_KEY,
|
|
||||||
IMPERSONATION_KEY,
|
IMPERSONATION_KEY,
|
||||||
type ImpersonationState,
|
type ImpersonationState,
|
||||||
readImpersonationState,
|
readImpersonationState,
|
||||||
REFRESH_TOKEN_KEY,
|
|
||||||
stopImpersonation as restoreImpersonation,
|
stopImpersonation as restoreImpersonation,
|
||||||
USER_KEY,
|
USER_KEY,
|
||||||
} from '../utils/impersonation';
|
} from '../utils/impersonation';
|
||||||
@@ -290,11 +288,9 @@ export const Admin: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
|
||||||
const originalRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
||||||
const originalUser = localStorage.getItem(USER_KEY);
|
const originalUser = localStorage.getItem(USER_KEY);
|
||||||
if (!originalAccessToken || !originalRefreshToken || !originalUser) {
|
if (!originalUser) {
|
||||||
setError('Missing current session tokens.');
|
setError('Missing current session user state.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,8 +303,6 @@ export const Admin: React.FC = () => {
|
|||||||
|
|
||||||
const state: ImpersonationState = {
|
const state: ImpersonationState = {
|
||||||
original: {
|
original: {
|
||||||
accessToken: originalAccessToken,
|
|
||||||
refreshToken: originalRefreshToken,
|
|
||||||
user: JSON.parse(originalUser),
|
user: JSON.parse(originalUser),
|
||||||
},
|
},
|
||||||
impersonator: {
|
impersonator: {
|
||||||
@@ -325,8 +319,6 @@ export const Admin: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state));
|
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));
|
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||||
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@@ -339,9 +331,26 @@ export const Admin: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopImpersonation = () => {
|
const stopImpersonation = async () => {
|
||||||
if (!restoreImpersonation()) return;
|
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';
|
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) {
|
if (authEnabled === null) {
|
||||||
|
|||||||
@@ -962,7 +962,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={clsx("grid gap-3 sm:gap-4 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}
|
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 ? (
|
{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">
|
<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
|
? window.location.origin
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:8000');
|
: (import.meta.env.VITE_API_URL || 'http://localhost:8000');
|
||||||
|
|
||||||
const authToken = localStorage.getItem('excalidash-access-token');
|
|
||||||
const socket = io(socketUrl, {
|
const socket = io(socketUrl, {
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
auth: authToken ? { token: authToken } : {},
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import * as api from '../api';
|
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 = () => {
|
export const Login: React.FC = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -81,8 +81,6 @@ export const Login: React.FC = () => {
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}>('/auth/must-reset-password', { newPassword });
|
}>('/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));
|
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||||
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { Collection } from '../types';
|
import type { Collection } from '../types';
|
||||||
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
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 = () => {
|
export const Profile: React.FC = () => {
|
||||||
const { user: authUser, logout, authEnabled } = useAuth();
|
const { user: authUser, logout, authEnabled } = useAuth();
|
||||||
@@ -234,8 +234,6 @@ export const Profile: React.FC = () => {
|
|||||||
currentPassword: emailCurrentPassword,
|
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));
|
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||||
|
|
||||||
setSuccess('Email updated successfully');
|
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 USER_KEY = 'excalidash-user';
|
||||||
export const IMPERSONATION_KEY = 'excalidash-impersonation';
|
export const IMPERSONATION_KEY = 'excalidash-impersonation';
|
||||||
|
|
||||||
export type ImpersonationState = {
|
export type ImpersonationState = {
|
||||||
original: {
|
original: {
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: unknown;
|
user: unknown;
|
||||||
};
|
};
|
||||||
impersonator: {
|
impersonator: {
|
||||||
@@ -28,7 +24,7 @@ export const readImpersonationState = (): ImpersonationState | null => {
|
|||||||
const raw = localStorage.getItem(IMPERSONATION_KEY);
|
const raw = localStorage.getItem(IMPERSONATION_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as ImpersonationState;
|
const parsed = JSON.parse(raw) as ImpersonationState;
|
||||||
if (!parsed?.original?.accessToken || !parsed?.original?.refreshToken) return null;
|
if (!parsed?.original?.user) return null;
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -38,10 +34,7 @@ export const readImpersonationState = (): ImpersonationState | null => {
|
|||||||
export const stopImpersonation = (): boolean => {
|
export const stopImpersonation = (): boolean => {
|
||||||
const state = readImpersonationState();
|
const state = readImpersonationState();
|
||||||
if (!state) return false;
|
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.setItem(USER_KEY, JSON.stringify(state.original.user));
|
||||||
localStorage.removeItem(IMPERSONATION_KEY);
|
localStorage.removeItem(IMPERSONATION_KEY);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user