diff --git a/Dockerfile b/Dockerfile index 76bdfa4..64c47f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ FROM node:22-alpine WORKDIR /app RUN apk add --no-cache libcap-setcap su-exec tini && corepack enable COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --prod --frozen-lockfile +RUN pnpm install --prod --no-frozen-lockfile COPY src ./src COPY --from=caddy /usr/bin/caddy /usr/bin/caddy COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh diff --git a/package.json b/package.json index 5de183e..30c9d2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.11.76", + "version": "0.11.77", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", @@ -11,7 +11,8 @@ "dependencies": { "adm-zip": "0.5.16", "express": "5.1.0", - "multer": "2.0.2" + "multer": "2.0.2", + "qrcode": "1.5.4" }, "engines": { "node": ">=22" diff --git a/src/public/app.js b/src/public/app.js index e882174..ba468a2 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -24,7 +24,7 @@ async function api(url, options = {}) { if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); } return response.status === 204 ? null : response.json(); } -function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; } +function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; } function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); } function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; } @@ -315,6 +315,19 @@ function renderUsers() { document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = ''; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); }); } +function renderAccount() { + if (!state.user) return; + $("#account-display-name").textContent = state.user.displayName || "—"; + $("#account-username").textContent = state.user.username || "—"; + $("#account-role").textContent = state.user.role === "administrator" ? "Administrator" : state.user.role === "viewer" ? "Viewer" : "Standard User"; + const enabled = Boolean(state.user.mfaEnabled); + const pill = $("#account-mfa-status"); + pill.innerHTML = `${enabled ? "On" : "Off"}`; + $("#account-mfa-enable").classList.toggle("hidden", enabled); + $("#account-mfa-disable").classList.toggle("hidden", !enabled); + $("#account-mfa-recovery").classList.toggle("hidden", !enabled); +} + async function loadFeatureView() { if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); } if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); } @@ -332,7 +345,7 @@ function render() { $("#dashboard-view").classList.toggle("hidden", !overview); const management = state.view === "hosted" || state.view === "proxies"; $("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "streaming" || state.view === "redirects" || state.view === "access")); - $("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); + $("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account"); if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); } $("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation"); const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users"; @@ -344,7 +357,7 @@ function render() { return; } if (!management) { - const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."] }; + const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] }; const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1]; $("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent; if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0); @@ -352,7 +365,7 @@ function render() { if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host"; if (state.view === "redirects") $("#redirect-empty").classList.toggle("hidden", !state.loaded || state.redirects.length > 0); if (state.view === "redirects") { const items = state.redirects; const running = items.filter(item => item.enabled !== false).length, disabled = items.length - running; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = 0; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = "status-dot inactive"; $(".port-note").classList.add("hidden"); } - if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs(); else if (state.view === "performance") renderPerformance(); + if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs(); else if (state.view === "performance") renderPerformance(); else if (state.view === "account") renderAccount(); return; } const items = state.view === "hosted" ? state.sites : state.proxies; @@ -402,7 +415,9 @@ async function boot() { if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000); } -$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); await boot(); } catch (error) { $("#login-error").textContent = error.message; } }); +$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { const result = await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (result?.mfaRequired) { $("#login-form").classList.add("hidden"); $("#mfa-login-form").classList.remove("hidden"); $("#mfa-login-form [name=code]").focus(); return; } event.target.reset(); await boot(); } catch (error) { $("#login-error").textContent = error.message; } }); +$("#mfa-login-form").addEventListener("submit", async event => { event.preventDefault(); $("#mfa-login-error").textContent = ""; try { const response = await fetch("/api/login/mfa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || "That code didn't match. Try again."); event.target.reset(); await boot(); } catch (error) { $("#mfa-login-error").textContent = error.message; } }); +$("#mfa-login-cancel").addEventListener("click", () => { $("#mfa-login-form").reset(); $("#mfa-login-error").textContent = ""; $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); }); $("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } }); $("#setup-dialog").addEventListener("cancel", event => event.preventDefault()); $("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); }); @@ -582,3 +597,81 @@ document.addEventListener("click", event => { if (event.target.closest(".create- document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); }); setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500); function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } + +$("#account-password-form").addEventListener("submit", async event => { + event.preventDefault(); + $("#account-password-error").textContent = ""; + const form = event.target; + const body = Object.fromEntries(new FormData(form)); + if (String(body.newPassword) !== String(body.confirmPassword)) { $("#account-password-error").textContent = "The new passwords do not match."; return; } + try { + await api("/api/account/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword: body.currentPassword, newPassword: body.newPassword }) }); + form.reset(); toast("Password changed."); + } catch (error) { $("#account-password-error").textContent = error.message; } +}); + +let mfaPasswordResolve = null; +function requestMfaPassword(title, heading) { + $("#mfa-password-title").textContent = title; + $("#mfa-password-heading").textContent = heading; + $("#mfa-password-error").textContent = ""; + $("#mfa-password-form").reset(); + $("#mfa-password-dialog").showModal(); + return new Promise(resolve => { mfaPasswordResolve = resolve; }); +} +$("#mfa-password-form").addEventListener("submit", event => { + event.preventDefault(); + const password = new FormData(event.target).get("password"); + $("#mfa-password-dialog").close(); + mfaPasswordResolve?.(password); + mfaPasswordResolve = null; +}); +$("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; }); + +$("#account-mfa-enable").addEventListener("click", async () => { + try { + const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + $("#mfa-setup-qr").innerHTML = result.qrSvg; + $("#mfa-setup-secret").textContent = result.secret; + $("#mfa-setup-error").textContent = ""; + $("#mfa-setup-confirm-form").reset(); + $("#mfa-setup-dialog").showModal(); + } catch (error) { toast(error.message); } +}); +$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); }); +$("#mfa-setup-confirm-form").addEventListener("submit", async event => { + event.preventDefault(); + $("#mfa-setup-error").textContent = ""; + try { + const code = new FormData(event.target).get("code"); + const result = await api("/api/account/mfa/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) }); + $("#mfa-setup-dialog").close(); + state.user.mfaEnabled = true; + renderAccount(); + $("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n"); + $("#mfa-recovery-dialog").showModal(); + toast("Two-factor authentication enabled."); + } catch (error) { $("#mfa-setup-error").textContent = error.message; } +}); +$("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); }); + +$("#account-mfa-disable").addEventListener("click", async () => { + const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue"); + if (!password) return; + try { + await api("/api/account/mfa/disable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) }); + state.user.mfaEnabled = false; + renderAccount(); + toast("Two-factor authentication disabled."); + } catch (error) { toast(error.message); } +}); +$("#account-mfa-recovery").addEventListener("click", async () => { + const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue"); + if (!password) return; + try { + const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) }); + $("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n"); + $("#mfa-recovery-dialog").showModal(); + toast("Recovery codes regenerated. Your old codes no longer work."); + } catch (error) { toast(error.message); } +}); diff --git a/src/public/index.html b/src/public/index.html index a606e99..b6aa711 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -7,7 +7,7 @@ Site Gateway - + @@ -58,7 +71,7 @@ -
+
@@ -74,7 +87,7 @@ - +

Gateway control

Dashboard

Health, activity, and system status at a glance.

@@ -179,6 +192,67 @@ + + +
+

Two-factor authentication

+

Scan this code

+

Scan with an authenticator app (Google Authenticator, 1Password, Authy, etc.), or enter the key manually.

+
+

Manual entry key:

+
+ + +
+
+
+
+ +
+

Confirm your password

+

Confirm it's you

+ + +
+
+
+ +
+

Save these now

+

Your recovery codes

+

Each code can be used once to sign in if you lose access to your authenticator app. Store them somewhere safe — they won't be shown again.

+

+          
+
+
@@ -283,6 +357,6 @@
- + diff --git a/src/public/styles.css b/src/public/styles.css index 10b8be1..004a268 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -280,3 +280,7 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re .performance-table .count-divider{color:var(--muted);margin:0 2px} .performance-table td .http-status.bad[title]{cursor:help;text-decoration:underline dotted;text-underline-offset:3px} @media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:30%}} +.mfa-qr{display:flex;justify-content:center;padding:16px;background:#fff;border-radius:12px;margin:16px 0} +.mfa-qr svg{width:200px;height:200px} +.mfa-recovery-codes{background:#0a1423;border:1px solid var(--line);border-radius:10px;padding:16px;font-size:14px;line-height:1.8;letter-spacing:.02em;white-space:pre-wrap;user-select:all} +@media(prefers-color-scheme:light){.mfa-recovery-codes{background:#f3f6fa}} diff --git a/src/server.js b/src/server.js index 82bd1c9..9c65dbc 100644 --- a/src/server.js +++ b/src/server.js @@ -12,7 +12,9 @@ import { promisify } from "node:util"; import AdmZip from "adm-zip"; import express from "express"; import multer from "multer"; +import QRCode from "qrcode"; import { LOCAL_INSTANCE_ID, openStorage } from "./storage.js"; +import { generateTotpSecret, verifyTotp, otpauthUri, generateRecoveryCodes } from "./totp.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageMetadata = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); @@ -111,8 +113,8 @@ async function passwordMatches(password, record) { } function publicUser(user) { - const { password, sessionVersion, ...safe } = user; - return safe; + const { password, sessionVersion, mfaSecret, mfaPendingSecret, mfaRecoveryCodes, ...safe } = user; + return { ...safe, mfaEnabled: Boolean(user.mfaEnabled) }; } function activeAdministrators() { @@ -193,6 +195,8 @@ async function loadSites() { for (const user of users) { if (user.setupRequired === undefined) { user.setupRequired = false; usersChanged = true; } if (!user.sessionVersion) { user.sessionVersion = crypto.randomBytes(16).toString("hex"); usersChanged = true; } + if (user.mfaEnabled === undefined) { user.mfaEnabled = false; usersChanged = true; } + if (!Array.isArray(user.mfaRecoveryCodes)) { user.mfaRecoveryCodes = []; usersChanged = true; } } if (usersChanged) await saveUsers(); redirects = storage.loadCollection("redirects"); @@ -1045,11 +1049,34 @@ app.get("/api/session", (req, res) => { const user = sessionUser(req); res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null }); }); +function checkLoginRateLimit(key) { + const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 }; + if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; } + return attempt; +} +function issueSessionCookie(res, user) { + if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex"); + const expires = String(Date.now() + 12 * 60 * 60 * 1000); + const value = `${user.id}.${expires}.${user.sessionVersion}`; + res.setHeader("Set-Cookie", [`webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`, "pending_mfa=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"]); +} +function issuePendingMfaCookie(res, user) { + const expires = String(Date.now() + 5 * 60 * 1000); + const value = `${user.id}.${expires}.mfa`; + res.setHeader("Set-Cookie", `pending_mfa=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=300`); +} +function pendingMfaUser(req) { + const token = cookieMap(req.headers.cookie).pending_mfa; + if (!token) return null; + const [userId, expires, marker, signature] = token.split("."); + const user = users.find(item => item.id === userId && item.status === "active"); + if (!user || marker !== "mfa" || !expires || Number(expires) <= Date.now() || !safeEqual(signature || "", sign(`${userId}.${expires}.${marker}`))) return null; + return user; +} app.post("/api/login", async (req, res, next) => { try { const key = req.ip || req.socket.remoteAddress || "unknown"; - const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 }; - if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; } + const attempt = checkLoginRateLimit(key); if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); } const username = String(req.body.username || "").trim().toLowerCase(); const user = users.find(item => item.username === username); @@ -1058,11 +1085,36 @@ app.post("/api/login", async (req, res, next) => { return res.status(401).json({ error: "Incorrect username or password." }); } loginAttempts.delete(key); + if (user.mfaEnabled) { issuePendingMfaCookie(res, user); return res.json({ mfaRequired: true }); } user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers(); - if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex"); - const expires = String(Date.now() + 12 * 60 * 60 * 1000); - const value = `${user.id}.${expires}.${user.sessionVersion}`; - res.setHeader("Set-Cookie", `webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`); + issueSessionCookie(res, user); + res.json({ ok: true, user: publicUser(user) }); + } catch (error) { next(error); } +}); +app.post("/api/login/mfa", async (req, res, next) => { + try { + const key = req.ip || req.socket.remoteAddress || "unknown"; + const attempt = checkLoginRateLimit(key); + if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); } + const user = pendingMfaUser(req); + if (!user || !user.mfaEnabled) { attempt.count += 1; loginAttempts.set(key, attempt); return res.status(401).json({ error: "Your sign-in session expired. Please sign in again." }); } + const code = String(req.body.code || "").trim(); + let matchedRecoveryCode = null; + const isValidTotp = verifyTotp(user.mfaSecret, code); + if (!isValidTotp) { + for (const entry of user.mfaRecoveryCodes || []) { + if (entry.usedAt) continue; + if (await passwordMatches(code, entry.hash)) { matchedRecoveryCode = entry; break; } + } + } + if (!isValidTotp && !matchedRecoveryCode) { + attempt.count += 1; loginAttempts.set(key, attempt); recordActivity(`Security: failed two-factor code for “${user.username}”.`, "error"); + return res.status(401).json({ error: "That code didn't match. Try again." }); + } + loginAttempts.delete(key); + if (matchedRecoveryCode) { matchedRecoveryCode.usedAt = new Date().toISOString(); recordActivity(`User “${user.username}” signed in using a two-factor recovery code.`); } + user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers(); + issueSessionCookie(res, user); res.json({ ok: true, user: publicUser(user) }); } catch (error) { next(error); } }); @@ -1124,7 +1176,65 @@ app.post("/api/setup/admin", async (req, res, next) => { } catch (error) { next(error); } }); app.use("/api", (req, res, next) => { currentAuditActor = req.user?.id || null; return req.user.setupRequired ? res.status(428).json({ error: "Complete the initial administrator setup before continuing." }) : next(); }); -app.use("/api", (req, res, next) => { if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); }); +app.use("/api", (req, res, next) => { if (req.path.startsWith("/account/")) return next(); if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); }); +app.post("/api/account/password", async (req, res, next) => { + try { + const currentPassword = String(req.body.currentPassword || ""); + const newPassword = String(req.body.newPassword || ""); + if (!await passwordMatches(currentPassword, req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); + if (newPassword.length < 8) return res.status(400).json({ error: "New password must contain at least 8 characters." }); + req.user.password = await passwordRecord(newPassword); + req.user.updatedAt = new Date().toISOString(); + await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`); + issueSessionCookie(res, req.user); + res.json({ ok: true }); + } catch (error) { next(error); } +}); +app.post("/api/account/mfa/setup", async (req, res, next) => { + try { + if (req.user.mfaEnabled) return res.status(409).json({ error: "Two-factor authentication is already enabled. Disable it first to start over." }); + const secret = generateTotpSecret(); + req.user.mfaPendingSecret = secret; + await saveUsers(); + const uri = otpauthUri({ secret, username: req.user.username }); + const qrSvg = await QRCode.toString(uri, { type: "svg", margin: 1, width: 220 }); + res.json({ secret, otpauthUri: uri, qrSvg }); + } catch (error) { next(error); } +}); +app.post("/api/account/mfa/confirm", async (req, res, next) => { + try { + if (!req.user.mfaPendingSecret) return res.status(400).json({ error: "Start two-factor setup before confirming a code." }); + if (!verifyTotp(req.user.mfaPendingSecret, req.body.code)) return res.status(400).json({ error: "That code didn't match. Try again." }); + req.user.mfaSecret = req.user.mfaPendingSecret; + req.user.mfaPendingSecret = null; + req.user.mfaEnabled = true; + const codes = generateRecoveryCodes(10); + req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null }))); + req.user.updatedAt = new Date().toISOString(); + await saveUsers(); recordActivity(`User “${req.user.username}” enabled two-factor authentication.`); + res.json({ ok: true, recoveryCodes: codes }); + } catch (error) { next(error); } +}); +app.post("/api/account/mfa/disable", async (req, res, next) => { + try { + if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); + req.user.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = []; + req.user.updatedAt = new Date().toISOString(); + await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning"); + res.json({ ok: true }); + } catch (error) { next(error); } +}); +app.post("/api/account/mfa/recovery-codes", async (req, res, next) => { + try { + if (!req.user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn't enabled." }); + if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); + const codes = generateRecoveryCodes(10); + req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null }))); + req.user.updatedAt = new Date().toISOString(); + await saveUsers(); recordActivity(`User “${req.user.username}” regenerated two-factor recovery codes.`); + res.json({ ok: true, recoveryCodes: codes }); + } catch (error) { next(error); } +}); app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError } })); app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." })); diff --git a/src/totp.js b/src/totp.js new file mode 100644 index 0000000..b73a63a --- /dev/null +++ b/src/totp.js @@ -0,0 +1,74 @@ +import crypto from "node:crypto"; + +// Minimal RFC 4648 base32 (no padding), and RFC 6238 TOTP on top of RFC 4226 HOTP. +// Implemented against Node's built-in crypto only — no third-party dependency. + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Encode(buffer) { + let bits = ""; + for (const byte of buffer) bits += byte.toString(2).padStart(8, "0"); + let output = ""; + for (let i = 0; i + 5 <= bits.length; i += 5) output += BASE32_ALPHABET[parseInt(bits.slice(i, i + 5), 2)]; + const remainder = bits.length % 5; + if (remainder) output += BASE32_ALPHABET[parseInt(bits.slice(bits.length - remainder).padEnd(5, "0"), 2)]; + return output; +} + +export function base32Decode(value) { + const cleaned = String(value || "").toUpperCase().replace(/[^A-Z2-7]/g, ""); + let bits = ""; + for (const char of cleaned) { + const index = BASE32_ALPHABET.indexOf(char); + if (index < 0) continue; + bits += index.toString(2).padStart(5, "0"); + } + const bytes = []; + for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2)); + return Buffer.from(bytes); +} + +export function generateTotpSecret() { + return base32Encode(crypto.randomBytes(20)); // 160-bit key, standard for authenticator apps +} + +function hotp(secretBuffer, counter, digits = 6) { + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const hmac = crypto.createHmac("sha1", secretBuffer).update(counterBuffer).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); + return String(binary % 10 ** digits).padStart(digits, "0"); +} + +export function totpAt(base32Secret, forTime = Date.now(), step = 30, digits = 6) { + const counter = Math.floor(forTime / 1000 / step); + return hotp(base32Decode(base32Secret), counter, digits); +} + +// Accepts a code from the current step or one step on either side, to tolerate normal clock drift. +export function verifyTotp(base32Secret, code, { step = 30, digits = 6, window = 1, forTime = Date.now() } = {}) { + const candidate = String(code || "").trim().replace(/\s+/g, ""); + if (!/^\d{6,8}$/.test(candidate)) return false; + const secretBuffer = base32Decode(base32Secret); + const baseCounter = Math.floor(forTime / 1000 / step); + for (let offset = -window; offset <= window; offset++) { + const expected = hotp(secretBuffer, baseCounter + offset, digits); + if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.padStart(digits, "0")))) return true; + } + return false; +} + +export function otpauthUri({ secret, username, issuer = "Site Gateway" }) { + const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(username)}`; + return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; +} + +export function generateRecoveryCodes(count = 10) { + const codes = []; + for (let i = 0; i < count; i++) { + const raw = crypto.randomBytes(5).toString("hex").toUpperCase(); // 10 hex chars + codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`); + } + return codes; +}