feat: implement basic authentication system

This commit is contained in:
Adrian Acala
2026-01-16 21:34:58 -08:00
parent d1dbde95e4
commit 20ef4ee295
26 changed files with 975 additions and 23 deletions
+3 -1
View File
@@ -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
+62
View File
@@ -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");
});
});
+215
View File
@@ -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
View File
@@ -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",