Add authentication feature with session management
- Implemented authentication logic in src/server/auth.js, including session token creation and verification. - Integrated authentication routes for login, logout, and user session retrieval in the Fastify server. - Updated docker-compose.yml to include optional authentication environment variables. - Added @fastify/cookie dependency for cookie management in package.json and package-lock.json. - Enhanced the frontend with login form and session handling in App.svelte, including UI updates for authenticated states. - Styled authentication components in app.css for better user experience.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const COOKIE_NAME = "session";
|
||||
const PUBLIC_AUTH_PATHS = new Set([
|
||||
"GET /api/auth/me",
|
||||
"POST /api/auth/login",
|
||||
"POST /api/auth/logout",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {object} AuthConfig
|
||||
* @property {boolean} enabled - Whether AUTH_PASSWORD is set.
|
||||
* @property {string} username - Expected username (default admin).
|
||||
* @property {string} password - Expected password when enabled.
|
||||
* @property {string} secret - HMAC secret for session cookies.
|
||||
* @property {number} ttlMs - Session lifetime in milliseconds.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reads auth settings from the environment.
|
||||
* @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty).
|
||||
*/
|
||||
function loadAuthConfig() {
|
||||
const password = (process.env.AUTH_PASSWORD || "").trim();
|
||||
const enabled = password.length > 0;
|
||||
const username = (process.env.AUTH_USERNAME || "admin").trim() || "admin";
|
||||
const secret = (process.env.SESSION_SECRET || "").trim();
|
||||
const ttlHoursRaw = Number(process.env.SESSION_TTL_HOURS);
|
||||
const ttlHours =
|
||||
Number.isFinite(ttlHoursRaw) && ttlHoursRaw > 0 ? ttlHoursRaw : 168;
|
||||
|
||||
if (enabled && secret.length < 32) {
|
||||
throw new Error(
|
||||
"SESSION_SECRET is required when AUTH_PASSWORD is set (min 32 characters).",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
username,
|
||||
password,
|
||||
secret,
|
||||
ttlMs: Math.round(ttlHours * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} input - Submitted password.
|
||||
* @param {string} expected - Configured password.
|
||||
* @returns {boolean} Whether they match (timing-safe when lengths match).
|
||||
*/
|
||||
function verifyPassword(input, expected) {
|
||||
const a = Buffer.from(String(input), "utf8");
|
||||
const b = Buffer.from(String(expected), "utf8");
|
||||
if (a.length !== b.length) {
|
||||
// Dummy compare so length mismatch still spends hashing time.
|
||||
crypto.timingSafeEqual(b, b);
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} username - Session subject.
|
||||
* @param {string} secret - HMAC secret.
|
||||
* @param {number} expiresAt - Unix epoch milliseconds.
|
||||
* @returns {string} Signed cookie value.
|
||||
*/
|
||||
function createSessionToken(username, secret, expiresAt) {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ u: username, exp: expiresAt }),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} token - Cookie value.
|
||||
* @param {string} secret - HMAC secret.
|
||||
* @returns {{ username: string, expiresAt: number }|null} Parsed session or null.
|
||||
*/
|
||||
function parseSessionToken(token, secret) {
|
||||
if (typeof token !== "string" || token.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const dot = token.lastIndexOf(".");
|
||||
if (dot <= 0) {
|
||||
return null;
|
||||
}
|
||||
const payload = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
const sigBuf = Buffer.from(sig);
|
||||
const expBuf = Buffer.from(expected);
|
||||
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
if (
|
||||
typeof data?.u !== "string" ||
|
||||
typeof data?.exp !== "number" ||
|
||||
!Number.isFinite(data.exp)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= data.exp) {
|
||||
return null;
|
||||
}
|
||||
return { username: data.u, expiresAt: data.exp };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} request - Incoming request.
|
||||
* @param {AuthConfig} config - Auth config.
|
||||
* @returns {{ username: string, expiresAt: number }|null} Session when valid.
|
||||
*/
|
||||
function sessionFromRequest(request, config) {
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
const raw = request.cookies?.[COOKIE_NAME];
|
||||
return parseSessionToken(raw, config.secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie options for setting/clearing the session.
|
||||
* @param {AuthConfig} config - Auth config.
|
||||
* @param {number} [maxAgeMs] - Max-Age in ms (omit when clearing).
|
||||
* @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options.
|
||||
*/
|
||||
function sessionCookieOptions(config, maxAgeMs) {
|
||||
/** @type {import("@fastify/cookie").CookieSerializeOptions} */
|
||||
const opts = {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
};
|
||||
if (typeof maxAgeMs === "number") {
|
||||
opts.maxAge = Math.floor(maxAgeMs / 1000);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers auth routes and an onRequest guard for /api/*.
|
||||
* @param {import("fastify").FastifyInstance} app - Fastify instance (cookie plugin already registered).
|
||||
* @param {AuthConfig} config - From {@link loadAuthConfig}.
|
||||
* @returns {void}
|
||||
*/
|
||||
function registerAuth(app, config) {
|
||||
app.decorateRequest("authUser", null);
|
||||
|
||||
app.get("/api/auth/me", async (request) => {
|
||||
if (!config.enabled) {
|
||||
return { authRequired: false, authenticated: true };
|
||||
}
|
||||
const session = sessionFromRequest(request, config);
|
||||
if (!session) {
|
||||
return { authRequired: true, authenticated: false };
|
||||
}
|
||||
return {
|
||||
authRequired: true,
|
||||
authenticated: true,
|
||||
username: session.username,
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
if (!config.enabled) {
|
||||
return { ok: true, authRequired: false, authenticated: true };
|
||||
}
|
||||
|
||||
const body = request.body || {};
|
||||
const username =
|
||||
typeof body.username === "string" && body.username.trim().length > 0
|
||||
? body.username.trim()
|
||||
: config.username;
|
||||
const password = typeof body.password === "string" ? body.password : "";
|
||||
|
||||
const userOk =
|
||||
username.length === config.username.length &&
|
||||
crypto.timingSafeEqual(
|
||||
Buffer.from(username, "utf8"),
|
||||
Buffer.from(config.username, "utf8"),
|
||||
);
|
||||
const passOk = verifyPassword(password, config.password);
|
||||
|
||||
if (!userOk || !passOk) {
|
||||
return reply.code(401).send({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + config.ttlMs;
|
||||
const token = createSessionToken(config.username, config.secret, expiresAt);
|
||||
reply.setCookie(COOKIE_NAME, token, sessionCookieOptions(config, config.ttlMs));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authRequired: true,
|
||||
authenticated: true,
|
||||
username: config.username,
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config));
|
||||
return { ok: true, authenticated: false };
|
||||
});
|
||||
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
if (!config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlPath = request.url.split("?")[0];
|
||||
if (!urlPath.startsWith("/api/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${request.method.toUpperCase()} ${urlPath}`;
|
||||
if (PUBLIC_AUTH_PATHS.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionFromRequest(request, config);
|
||||
if (!session) {
|
||||
return reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
request.authUser = session.username;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
createSessionToken,
|
||||
loadAuthConfig,
|
||||
parseSessionToken,
|
||||
registerAuth,
|
||||
sessionCookieOptions,
|
||||
sessionFromRequest,
|
||||
verifyPassword,
|
||||
};
|
||||
@@ -3,9 +3,12 @@ const fs = require("fs");
|
||||
const Fastify = require("fastify");
|
||||
const fastifyStatic = require("@fastify/static");
|
||||
|
||||
const fastifyCookie = require("@fastify/cookie");
|
||||
|
||||
const { openDatabase } = require("./db.js");
|
||||
const { DownloadQueue } = require("./queue.js");
|
||||
const { startWatcher } = require("./watcher.js");
|
||||
const { loadAuthConfig, registerAuth } = require("./auth.js");
|
||||
const routesPlugin = require("./routes.js");
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
@@ -25,6 +28,7 @@ async function main() {
|
||||
|
||||
const db = openDatabase(DATA_DIR);
|
||||
const queue = new DownloadQueue(db);
|
||||
const authConfig = loadAuthConfig();
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Allow POST/PATCH with Content-Type: application/json and an empty body
|
||||
@@ -44,6 +48,8 @@ async function main() {
|
||||
},
|
||||
);
|
||||
|
||||
await app.register(fastifyCookie);
|
||||
registerAuth(app, authConfig);
|
||||
await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR });
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
@@ -84,6 +90,11 @@ async function main() {
|
||||
app.log.info(`Data dir: ${DATA_DIR}`);
|
||||
app.log.info(`Watch cron: ${WATCH_CRON}`);
|
||||
app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`);
|
||||
app.log.info(
|
||||
authConfig.enabled
|
||||
? `Auth: enabled (user=${authConfig.username})`
|
||||
: "Auth: disabled (set AUTH_PASSWORD to enable)",
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user