feat: implement basic authentication system
This commit is contained in:
@@ -2,4 +2,6 @@
|
||||
PORT=8000
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=file:/app/prisma/dev.db
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
# Optional auth cookie settings: lax | strict | none
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
buildAuthConfig,
|
||||
createAuthSessionToken,
|
||||
getAuthSessionFromCookie,
|
||||
validateAuthSessionToken,
|
||||
verifyCredentials,
|
||||
} from "../auth";
|
||||
|
||||
describe("Auth utilities", () => {
|
||||
it("disables auth when credentials are missing", () => {
|
||||
const config = buildAuthConfig({});
|
||||
expect(config.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies credentials and validates issued session tokens", () => {
|
||||
const config = buildAuthConfig({
|
||||
AUTH_USERNAME: "admin",
|
||||
AUTH_PASSWORD: "super-secret",
|
||||
AUTH_SESSION_SECRET: "test-secret",
|
||||
});
|
||||
|
||||
expect(verifyCredentials(config, "admin", "super-secret")).toBe(true);
|
||||
expect(verifyCredentials(config, "admin", "wrong")).toBe(false);
|
||||
|
||||
const token = createAuthSessionToken(config, "admin");
|
||||
const session = validateAuthSessionToken(config, token);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.username).toBe("admin");
|
||||
});
|
||||
|
||||
it("rejects expired session tokens", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
|
||||
|
||||
const config = buildAuthConfig({
|
||||
AUTH_USERNAME: "admin",
|
||||
AUTH_PASSWORD: "secret",
|
||||
AUTH_SESSION_SECRET: "test-secret",
|
||||
AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds
|
||||
});
|
||||
|
||||
const token = createAuthSessionToken(config, "admin");
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
|
||||
|
||||
expect(validateAuthSessionToken(config, token)).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("extracts session tokens from cookies", () => {
|
||||
const config = buildAuthConfig({
|
||||
AUTH_USERNAME: "admin",
|
||||
AUTH_PASSWORD: "secret",
|
||||
AUTH_SESSION_SECRET: "test-secret",
|
||||
});
|
||||
|
||||
const token = createAuthSessionToken(config, "admin");
|
||||
const cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`;
|
||||
const session = getAuthSessionFromCookie(cookieHeader, config);
|
||||
expect(session?.username).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export type AuthSameSite = "lax" | "strict" | "none";
|
||||
|
||||
export type AuthConfig = {
|
||||
enabled: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
sessionTtlMs: number;
|
||||
cookieName: string;
|
||||
cookieSameSite: AuthSameSite;
|
||||
secret: Buffer;
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
username: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SESSION_TTL_HOURS = 24 * 7;
|
||||
const DEFAULT_COOKIE_NAME = "excalidash_auth";
|
||||
const DEFAULT_COOKIE_SAMESITE: AuthSameSite = "lax";
|
||||
|
||||
const base64UrlEncode = (input: Buffer | string): string => {
|
||||
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const base64UrlDecode = (input: string): Buffer => {
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64");
|
||||
};
|
||||
|
||||
const parseSessionTtlHours = (rawValue?: string): number => {
|
||||
if (!rawValue) return DEFAULT_SESSION_TTL_HOURS;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_SESSION_TTL_HOURS;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseSameSite = (rawValue?: string): AuthSameSite => {
|
||||
if (!rawValue) return DEFAULT_COOKIE_SAMESITE;
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === "none" || normalized === "strict" || normalized === "lax") {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_COOKIE_SAMESITE;
|
||||
};
|
||||
|
||||
const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => {
|
||||
if (!enabled) return Buffer.alloc(0);
|
||||
|
||||
const secretFromEnv = env.AUTH_SESSION_SECRET;
|
||||
if (secretFromEnv && secretFromEnv.trim().length > 0) {
|
||||
return Buffer.from(secretFromEnv, "utf8");
|
||||
}
|
||||
|
||||
const generated = crypto.randomBytes(32);
|
||||
const envLabel = env.NODE_ENV ? ` (${env.NODE_ENV})` : "";
|
||||
console.warn(
|
||||
`[security] AUTH_SESSION_SECRET is not set${envLabel}. ` +
|
||||
"Using an ephemeral per-process secret. Sessions will be invalidated on restart."
|
||||
);
|
||||
return generated;
|
||||
};
|
||||
|
||||
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
|
||||
const username = (env.AUTH_USERNAME || "").trim();
|
||||
const password = env.AUTH_PASSWORD || "";
|
||||
const enabled = username.length > 0 && password.length > 0;
|
||||
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
|
||||
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
|
||||
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
username,
|
||||
password,
|
||||
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
|
||||
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
|
||||
cookieSameSite,
|
||||
secret: resolveAuthSecret(enabled, env),
|
||||
};
|
||||
};
|
||||
|
||||
const signToken = (secret: Buffer, payloadB64: string): Buffer =>
|
||||
crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest();
|
||||
|
||||
const safeCompare = (left: string, right: string): boolean => {
|
||||
const leftHash = crypto.createHash("sha256").update(left, "utf8").digest();
|
||||
const rightHash = crypto.createHash("sha256").update(right, "utf8").digest();
|
||||
return crypto.timingSafeEqual(leftHash, rightHash);
|
||||
};
|
||||
|
||||
export const verifyCredentials = (
|
||||
config: AuthConfig,
|
||||
inputUsername: string,
|
||||
inputPassword: string
|
||||
): boolean => {
|
||||
if (!config.enabled) return false;
|
||||
return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword);
|
||||
};
|
||||
|
||||
export const createAuthSessionToken = (config: AuthConfig, username: string): string => {
|
||||
if (!config.enabled) {
|
||||
throw new Error("Authentication is not enabled.");
|
||||
}
|
||||
|
||||
const issuedAt = Date.now();
|
||||
const payload: AuthSession = {
|
||||
username,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + config.sessionTtlMs,
|
||||
};
|
||||
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadB64 = base64UrlEncode(payloadJson);
|
||||
const sigB64 = base64UrlEncode(signToken(config.secret, payloadB64));
|
||||
|
||||
return `${payloadB64}.${sigB64}`;
|
||||
};
|
||||
|
||||
export const validateAuthSessionToken = (
|
||||
config: AuthConfig,
|
||||
token: string | undefined | null
|
||||
): AuthSession | null => {
|
||||
if (!config.enabled || !token || typeof token !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [payloadB64, sigB64] = parts;
|
||||
try {
|
||||
const expectedSig = signToken(config.secret, payloadB64);
|
||||
const providedSig = base64UrlDecode(sigB64);
|
||||
if (providedSig.length !== expectedSig.length) return null;
|
||||
if (!crypto.timingSafeEqual(providedSig, expectedSig)) return null;
|
||||
|
||||
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
|
||||
const payload = JSON.parse(payloadJson) as Partial<AuthSession>;
|
||||
if (
|
||||
typeof payload.username !== "string" ||
|
||||
typeof payload.iat !== "number" ||
|
||||
typeof payload.exp !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > payload.exp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload as AuthSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseCookieHeader = (
|
||||
cookieHeader: string | undefined
|
||||
): Record<string, string> => {
|
||||
if (!cookieHeader) return {};
|
||||
return cookieHeader.split(";").reduce<Record<string, string>>((acc, part) => {
|
||||
const [rawKey, ...rest] = part.trim().split("=");
|
||||
if (!rawKey) return acc;
|
||||
const value = rest.join("=");
|
||||
acc[decodeURIComponent(rawKey)] = decodeURIComponent(value || "");
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getAuthSessionFromCookie = (
|
||||
cookieHeader: string | undefined,
|
||||
config: AuthConfig
|
||||
): AuthSession | null => {
|
||||
if (!config.enabled) return null;
|
||||
const cookies = parseCookieHeader(cookieHeader);
|
||||
const token = cookies[config.cookieName];
|
||||
return validateAuthSessionToken(config, token);
|
||||
};
|
||||
|
||||
export const buildAuthCookieOptions = (
|
||||
secure: boolean,
|
||||
sameSite: AuthSameSite,
|
||||
maxAgeMs?: number
|
||||
) => {
|
||||
const normalizedSameSite = sameSite === "none" ? "none" : sameSite;
|
||||
const options: {
|
||||
httpOnly: boolean;
|
||||
sameSite: AuthSameSite;
|
||||
secure: boolean;
|
||||
path: string;
|
||||
maxAge?: number;
|
||||
} = {
|
||||
httpOnly: true,
|
||||
sameSite: normalizedSameSite,
|
||||
secure: normalizedSameSite === "none" ? true : secure,
|
||||
path: "/",
|
||||
};
|
||||
if (typeof maxAgeMs === "number" && Number.isFinite(maxAgeMs) && maxAgeMs > 0) {
|
||||
options.maxAge = maxAgeMs;
|
||||
}
|
||||
return options;
|
||||
};
|
||||
+141
-1
@@ -23,6 +23,13 @@ import {
|
||||
getCsrfTokenHeader,
|
||||
getOriginFromReferer,
|
||||
} from "./security";
|
||||
import {
|
||||
buildAuthConfig,
|
||||
buildAuthCookieOptions,
|
||||
createAuthSessionToken,
|
||||
getAuthSessionFromCookie,
|
||||
verifyCredentials,
|
||||
} from "./auth";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -96,6 +103,13 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
|
||||
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
||||
console.log("Allowed origins:", allowedOrigins);
|
||||
|
||||
const authConfig = buildAuthConfig();
|
||||
if (authConfig.enabled) {
|
||||
console.log(`[auth] Enabled for user "${authConfig.username}".`);
|
||||
} else {
|
||||
console.log("[auth] Disabled (AUTH_USERNAME/AUTH_PASSWORD not set).");
|
||||
}
|
||||
|
||||
const uploadDir = path.resolve(__dirname, "../uploads");
|
||||
|
||||
const moveFile = async (source: string, destination: string) => {
|
||||
@@ -374,6 +388,52 @@ app.get("/csrf-token", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const isRequestSecure = (req: express.Request): boolean => {
|
||||
if (req.secure) return true;
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (Array.isArray(forwardedProto)) {
|
||||
return forwardedProto[0] === "https";
|
||||
}
|
||||
return forwardedProto === "https";
|
||||
};
|
||||
|
||||
const authExemptPaths = new Set([
|
||||
"/csrf-token",
|
||||
"/health",
|
||||
"/auth/status",
|
||||
"/auth/login",
|
||||
"/auth/logout",
|
||||
]);
|
||||
|
||||
const authMiddleware = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
) => {
|
||||
if (!authConfig.enabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (authExemptPaths.has(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
if (!session) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
res.locals.authUser = session.username;
|
||||
next();
|
||||
};
|
||||
|
||||
// CSRF validation middleware for state-changing requests
|
||||
const csrfProtectionMiddleware = (
|
||||
req: express.Request,
|
||||
@@ -438,9 +498,75 @@ const csrfProtectionMiddleware = (
|
||||
next();
|
||||
};
|
||||
|
||||
// Apply CSRF protection to all routes
|
||||
// Apply authentication and CSRF protection to all routes
|
||||
app.use(authMiddleware);
|
||||
app.use(csrfProtectionMiddleware);
|
||||
|
||||
const authLoginSchema = z.object({
|
||||
username: z.string().trim().min(1).max(200),
|
||||
password: z.string().min(1).max(512),
|
||||
});
|
||||
|
||||
app.get("/auth/status", (req, res) => {
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json({
|
||||
enabled: authConfig.enabled,
|
||||
authenticated: Boolean(session),
|
||||
user: session ? { username: session.username } : null,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/login", (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Authentication disabled",
|
||||
message: "Authentication is not enabled on this server.",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = authLoginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid payload",
|
||||
message: "Username and password are required.",
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
if (!verifyCredentials(authConfig, username, password)) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username or password.",
|
||||
});
|
||||
}
|
||||
|
||||
const token = createAuthSessionToken(authConfig, authConfig.username);
|
||||
res.cookie(
|
||||
authConfig.cookieName,
|
||||
token,
|
||||
buildAuthCookieOptions(
|
||||
isRequestSecure(req),
|
||||
authConfig.cookieSameSite,
|
||||
authConfig.sessionTtlMs
|
||||
)
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user: { username: authConfig.username },
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
res.clearCookie(
|
||||
authConfig.cookieName,
|
||||
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.json({ authenticated: false });
|
||||
});
|
||||
|
||||
const filesFieldSchema = z
|
||||
.union([z.record(z.string(), z.any()), z.null()])
|
||||
.optional()
|
||||
@@ -630,6 +756,20 @@ interface User {
|
||||
|
||||
const roomUsers = new Map<string, User[]>();
|
||||
|
||||
if (authConfig.enabled) {
|
||||
io.use((socket, next) => {
|
||||
const session = getAuthSessionFromCookie(
|
||||
socket.request.headers.cookie,
|
||||
authConfig
|
||||
);
|
||||
if (!session) {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
(socket as { authUser?: string }).authUser = session.username;
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
socket.on(
|
||||
"join-room",
|
||||
|
||||
Reference in New Issue
Block a user