fix csrf token hardset, remove cookie from localstorage
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-9
@@ -109,15 +109,19 @@ const initializeUploadDir = async () => {
|
||||
|
||||
const app = express();
|
||||
|
||||
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx.
|
||||
// Default to a single trusted proxy hop unless TRUST_PROXY is explicitly configured.
|
||||
// Set TRUST_PROXY=true only when you fully trust all upstream proxy hops.
|
||||
const trustProxyConfig = (process.env.TRUST_PROXY ?? "1").trim();
|
||||
const trustProxyValue = trustProxyConfig === "true"
|
||||
? true
|
||||
: trustProxyConfig === "false"
|
||||
? false
|
||||
: Number.parseInt(trustProxyConfig, 10) || 1;
|
||||
// Trust proxy headers (X-Forwarded-For, X-Real-IP) only when explicitly configured.
|
||||
// Safe default is disabled to avoid spoofed client IPs when running without a trusted proxy.
|
||||
// Set TRUST_PROXY=1 (or a specific hop count) when deploying behind reverse proxies.
|
||||
const trustProxyConfig = (process.env.TRUST_PROXY ?? "false").trim();
|
||||
const parsedProxyHops = Number.parseInt(trustProxyConfig, 10);
|
||||
const trustProxyValue =
|
||||
trustProxyConfig === "true"
|
||||
? true
|
||||
: trustProxyConfig === "false"
|
||||
? false
|
||||
: Number.isFinite(parsedProxyHops) && parsedProxyHops > 0
|
||||
? parsedProxyHops
|
||||
: false;
|
||||
app.set("trust proxy", trustProxyValue);
|
||||
|
||||
if (trustProxyValue === true) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { config } from "../config";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import { prisma as defaultPrisma } from "../db/prisma";
|
||||
import { createAuthModeService, type AuthModeService } from "../auth/authMode";
|
||||
import { ACCESS_TOKEN_COOKIE_NAME, readCookie } from "../auth/cookies";
|
||||
|
||||
// Extend Express Request type to include user
|
||||
declare global {
|
||||
@@ -46,14 +47,14 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
|
||||
|
||||
const extractToken = (req: Request): string | null => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || typeof authHeader !== "string") return null;
|
||||
|
||||
const parts = authHeader.split(" ");
|
||||
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
||||
return null;
|
||||
if (authHeader && typeof authHeader === "string") {
|
||||
const parts = authHeader.split(" ");
|
||||
if (parts.length === 2 && parts[0] === "Bearer") {
|
||||
return parts[1] || null;
|
||||
}
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
return readCookie(req, ACCESS_TOKEN_COOKIE_NAME);
|
||||
};
|
||||
|
||||
const verifyToken = (token: string): JwtPayload | null => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user