b6b40b0c1b
/users becomes /admin (redirected), a tab shell over the existing Users view and a new Server tab backed by GET /api/host (server/lib/host-stats.js): hostname/OS/uptime, CPU and load, memory and swap, every real disk with HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples — attributed by the member's Linux uid (S3) or, for host processes, by the inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the Server tab.
700 lines
27 KiB
JavaScript
700 lines
27 KiB
JavaScript
/**
|
||
* @file auth.js
|
||
* @description Server mode's accounts and login (docs/SERVER-MODE.md). Off
|
||
* unless `AGENTHUB_MODE=server`: in local mode every function here answers
|
||
* "allowed" and nothing reads the `users`/`auth_sessions` tables.
|
||
*
|
||
* - A password is stored as `scrypt$<salt>$<hash>` (node:crypto, async so a
|
||
* login never blocks the event loop) and compared in constant time.
|
||
* - A login is a random 32-byte token in the httpOnly, SameSite=Strict cookie
|
||
* `agenthub_sid`; only its SHA-256 is stored, for `SESSION_DAYS` days. A
|
||
* disabled user's sessions stop working at once (the lookup joins `users`).
|
||
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
|
||
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
|
||
* its own bearer token), and everything else only with a valid login.
|
||
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
|
||
* configuration — are an `admin`'s; an `analyst` and a `developer` write
|
||
* only their own rows (`scope.js`).
|
||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||
* admin on the Users page (`server/routes/users.js`), where every change
|
||
* needs the admin's own password again (`confirmPassword`, throttled).
|
||
* An account also carries an optional display name and email
|
||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||
* that step-up; changing their OWN password there needs the old one and
|
||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||
*
|
||
* This is login, not isolation: every member still sees every workspace and
|
||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||
* multi-user plan).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const crypto = require("node:crypto");
|
||
const { promisify } = require("node:util");
|
||
// Required on first use: a module that only loads this (websocket.js) must not
|
||
// open the database in local mode, where nothing here touches it.
|
||
const sql = (q) => require("../db").db.prepare(q);
|
||
const { isLocalRequest, isLoopback, isOwnHost } = require("./same-origin");
|
||
const { isHostAllowed } = require("./security");
|
||
|
||
const scrypt = promisify(crypto.scrypt);
|
||
|
||
const COOKIE = "agenthub_sid";
|
||
const SESSION_DAYS = 30;
|
||
const ROLES = ["admin", "analyst", "developer"];
|
||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||
|
||
/**
|
||
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
||
* profiles are not here: a member writes their own personal ones, and
|
||
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
|
||
*/
|
||
const ADMIN_WRITE_PREFIXES = [
|
||
"/settings",
|
||
"/updates",
|
||
"/cc-config",
|
||
"/codex-config",
|
||
"/git-credentials",
|
||
"/ai-tools",
|
||
"/import",
|
||
"/pricing",
|
||
"/budgets",
|
||
"/webhooks",
|
||
"/remote-sources",
|
||
"/reports",
|
||
"/pets",
|
||
"/users",
|
||
// The Claude/Codex account pool: tokens every member's panes run on.
|
||
"/accounts",
|
||
// Alert rules and budgets fire for every user's sessions.
|
||
"/alerts/rules",
|
||
];
|
||
|
||
/**
|
||
* API prefixes a developer may not reach at all, reads included: the host's own
|
||
* configuration and logs, and every view across all users (export, metrics,
|
||
* reports, webhook deliveries, remote machines, imports). Admin and analyst
|
||
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
|
||
*/
|
||
const STAFF_ONLY_PREFIXES = [
|
||
"/cc-config",
|
||
"/codex-config",
|
||
"/git-credentials",
|
||
"/remote-sources",
|
||
"/webhooks",
|
||
"/reports",
|
||
"/metrics",
|
||
"/import",
|
||
"/settings/export",
|
||
"/alerts/rules",
|
||
"/users",
|
||
"/accounts",
|
||
// The machine's hardware and every user's CPU/memory share.
|
||
"/host",
|
||
];
|
||
// The run form's `/` autocomplete lists commands and skills.
|
||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
||
|
||
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
||
|
||
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||
|
||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||
|
||
async function hashPassword(password) {
|
||
const salt = crypto.randomBytes(16);
|
||
const hash = await scrypt(password, salt, 64);
|
||
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
|
||
}
|
||
|
||
async function verifyPassword(password, stored) {
|
||
const [kind, salt, hash] = String(stored || "").split("$");
|
||
if (kind !== "scrypt" || !salt || !hash) return false;
|
||
const want = Buffer.from(hash, "base64");
|
||
const got = await scrypt(String(password), Buffer.from(salt, "base64"), want.length);
|
||
return crypto.timingSafeEqual(got, want);
|
||
}
|
||
|
||
function validateUsername(name) {
|
||
const n = String(name || "").trim();
|
||
if (!USERNAME_RE.test(n)) {
|
||
throw err("EBADUSER", "username: 1–64 letters, digits, '.', '_' or '-'");
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function validatePassword(password) {
|
||
// No minimum length (the operator's choice) — only an empty one is refused.
|
||
if (typeof password !== "string" || password.length === 0) {
|
||
throw err("EBADPASSWORD", "password: required");
|
||
}
|
||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||
}
|
||
|
||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||
|
||
const publicUser = (u) =>
|
||
u && {
|
||
id: u.id,
|
||
username: u.username,
|
||
role: u.role,
|
||
disabled: Boolean(u.disabled),
|
||
displayName: u.display_name ?? null,
|
||
email: u.email ?? null,
|
||
// When the avatar was last set (null = none): the client's cache-buster.
|
||
avatarAt: u.avatar_at ?? null,
|
||
createdAt: u.created_at ?? null,
|
||
};
|
||
|
||
const MAX_DISPLAY_NAME = 100;
|
||
const MAX_EMAIL = 254;
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
/**
|
||
* The profile fields present in `body`, normalized: trimmed, `""`/`null` ⇒
|
||
* null (clearing is legitimate). Fields absent from `body` are absent from the
|
||
* result, so a caller writes only what was sent. Throws EBADFIELD/EBADEMAIL.
|
||
*/
|
||
function validateProfile(body) {
|
||
const out = {};
|
||
if (body.displayName !== undefined) {
|
||
const v = body.displayName;
|
||
if (v !== null && typeof v !== "string") throw err("EBADFIELD", "displayName: a string");
|
||
const n = (v || "").trim();
|
||
// eslint-disable-next-line no-control-regex
|
||
if (n.length > MAX_DISPLAY_NAME || /[\u0000-\u001f\u007f]/.test(n)) {
|
||
throw err("EBADFIELD", `displayName: at most ${MAX_DISPLAY_NAME} printable characters`);
|
||
}
|
||
out.display_name = n || null;
|
||
}
|
||
if (body.email !== undefined) {
|
||
const v = body.email;
|
||
if (v !== null && typeof v !== "string") throw err("EBADEMAIL", "email: a string");
|
||
const e = (v || "").trim();
|
||
if (e && (e.length > MAX_EMAIL || !EMAIL_RE.test(e))) {
|
||
throw err("EBADEMAIL", "email: not a valid address");
|
||
}
|
||
out.email = e || null;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Write `validateProfile`'s result for one account. */
|
||
function setProfile(userId, fields) {
|
||
for (const [col, value] of Object.entries(fields)) {
|
||
// `col` is one of validateProfile's two fixed names, never request text.
|
||
if (col !== "display_name" && col !== "email") continue;
|
||
sql(`UPDATE users SET ${col} = ? WHERE id = ?`).run(value, userId);
|
||
}
|
||
}
|
||
|
||
function listUsers() {
|
||
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
|
||
}
|
||
|
||
function findUser(username) {
|
||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||
}
|
||
|
||
async function createUser(username, password, role = "developer") {
|
||
const name = validateUsername(username);
|
||
const linux = require("./linux-accounts");
|
||
linux.assertLinuxName(name);
|
||
validatePassword(password);
|
||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
|
||
const hash = await hashPassword(password);
|
||
// S3: the Linux account first — a user who cannot get one is not created.
|
||
linux.provision("create", name);
|
||
const { lastInsertRowid } = sql(
|
||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||
).run(name, role, hash);
|
||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||
// the background (each marketplace is a clone) — the user exists already.
|
||
require("./member-claude")
|
||
.applyPluginBaseline(name)
|
||
.catch((e) => console.warn(`[plugin-baseline] ${name}: ${e.message}`));
|
||
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
|
||
}
|
||
|
||
function requireUser(username) {
|
||
const u = findUser(username);
|
||
if (!u) throw err("ENOUSER", `no user ${username}`);
|
||
return u;
|
||
}
|
||
|
||
/** New password; every login of that user ends — but `keepToken`'s, when
|
||
* the user changed their own password from that login. */
|
||
async function setPassword(username, password, { keepToken } = {}) {
|
||
const u = requireUser(username);
|
||
validatePassword(password);
|
||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||
endSessions(u.id, keepToken);
|
||
}
|
||
|
||
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
|
||
function endSessions(userId, exceptToken) {
|
||
if (exceptToken) {
|
||
return sql("DELETE FROM auth_sessions WHERE user_id = ? AND token_hash != ?").run(
|
||
userId,
|
||
sha256(exceptToken)
|
||
).changes;
|
||
}
|
||
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
|
||
}
|
||
|
||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
|
||
function loginStats(userId) {
|
||
const r = sql(
|
||
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||
WHERE user_id = ? AND expires_at > ?`
|
||
).get(userId, new Date().toISOString());
|
||
return { logins: r.n, lastLoginAt: r.last || null };
|
||
}
|
||
|
||
function setRole(username, role) {
|
||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||
const u = requireUser(username);
|
||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||
if (role !== "admin") stopHostPanes(u.id);
|
||
}
|
||
|
||
/**
|
||
* S3: end the live panes of `userId`'s workspaces whose folder is no
|
||
* member's — they run as the HOST account, which only an admin's may (a
|
||
* new one is refused, EOWNERFOLDER; this ends the ones already running).
|
||
*/
|
||
function stopHostPanes(userId) {
|
||
if (!require("./linux-accounts").s3Enabled()) return;
|
||
const runAs = require("./run-as");
|
||
const pty = require("./pty-run");
|
||
for (const lane of sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(userId)) {
|
||
if (runAs.ownerForRead(lane.cwd)) continue; // a member's folder: their own account
|
||
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
|
||
try {
|
||
pty.killRun(pty.runIdForLane(lane.id, slot));
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Disable (ending every login) or re-enable a user — the reversible way out;
|
||
* `deleteUser` below is the one that removes an account for good. */
|
||
function setDisabled(username, disabled) {
|
||
const u = requireUser(username);
|
||
const linux = require("./linux-accounts");
|
||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||
// and that must never stop an admin from revoking their dashboard access.
|
||
const hasAccount = linux.s3Enabled() && require("./run-as").hasLinuxAccount(u.username);
|
||
if (!disabled && hasAccount) linux.provision("enable", u.username);
|
||
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
|
||
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||
// ...and their panes that run as the host (pkill below reaches only theirs).
|
||
if (disabled) stopHostPanes(u.id);
|
||
// Disable: the logins end FIRST, unconditionally; a failed lock still throws
|
||
// (EPROVISION), so the admin learns that the account's agents may still run.
|
||
if (disabled && hasAccount) linux.provision("disable", u.username);
|
||
}
|
||
|
||
/**
|
||
* DELETE a user — the Users page's "Delete user" (an admin, password
|
||
* confirmed by the route). In order, so a failure leaves the account usable
|
||
* rather than half gone: their logins end and the panes of their workspaces
|
||
* stop; under S3 the Linux account `ah-<name>` and its whole home are removed
|
||
* (`agenthub-provision delete`; EPROVISION stops here, with no row touched);
|
||
* without S3 their own folder under AGENTHUB_USERS_ROOT goes. Then the rows:
|
||
* their workspaces' records, prefs, push subscriptions, personal AI Proxy
|
||
* profiles and the user. Session history and the audit log stay. Never the
|
||
* last enabled admin (ELASTADMIN); the route refuses yourself (ESELF).
|
||
* A user made again under the same name gets a NEW, empty Linux account.
|
||
*/
|
||
function deleteUser(username) {
|
||
const u = requireUser(username);
|
||
if (u.role === "admin") {
|
||
const others = sql(
|
||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||
).get(u.id).n;
|
||
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||
}
|
||
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||
const pty = require("./pty-run");
|
||
for (const lane of lanes) {
|
||
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
|
||
try {
|
||
pty.killRun(pty.runIdForLane(lane.id, slot));
|
||
} catch {
|
||
/* not running */
|
||
}
|
||
}
|
||
}
|
||
const linux = require("./linux-accounts");
|
||
if (linux.s3Enabled()) {
|
||
// Idempotent: a user with no Linux account (made before S3) or no home
|
||
// still passes; anything the helper refuses stops the delete here.
|
||
if (linux.LINUX_NAME_RE.test(u.username)) linux.provision("delete", u.username);
|
||
} else {
|
||
const path = require("node:path");
|
||
const fs = require("node:fs");
|
||
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
||
if (root && /^[a-z][a-z0-9_-]{0,63}$/i.test(u.username)) {
|
||
const home = path.join(path.resolve(root), u.username);
|
||
// Only a real folder directly in the users root — never a link.
|
||
const st = fs.lstatSync(home, { throwIfNoEntry: false });
|
||
if (st?.isDirectory()) fs.rmSync(home, { recursive: true, force: true });
|
||
}
|
||
}
|
||
const lanesLib = require("./lanes");
|
||
for (const lane of lanes) lanesLib.deleteLane(lane.id);
|
||
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
|
||
require("./avatars").removeAvatar(u.id);
|
||
try {
|
||
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
|
||
} catch {
|
||
/* an older database without the column */
|
||
}
|
||
try {
|
||
require("node:fs").rmSync(
|
||
require("node:path").join(require("./ai-proxies").proxiesDir(), "users", String(u.id)),
|
||
{ recursive: true, force: true }
|
||
);
|
||
} catch {
|
||
/* no personal profiles */
|
||
}
|
||
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||
return { id: u.id, username: u.username, workspaces: lanes.length };
|
||
}
|
||
|
||
/** Give a workspace (a lane id) to a user — for the ones made before server
|
||
* mode, which have no owner and so reach no member. */
|
||
function assignWorkspace(laneId, username) {
|
||
const u = requireUser(username);
|
||
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(Number(laneId));
|
||
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
|
||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id);
|
||
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
|
||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||
}
|
||
|
||
/** Take a workspace back from the user who owns it: it is left with no owner
|
||
* (only staff see it) until it is given to someone. ENOTOWNED when `username`
|
||
* does not own it — this never takes one from a third user. */
|
||
function unassignWorkspace(laneId, username) {
|
||
const u = requireUser(username);
|
||
const lane = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
|
||
if (!lane) throw err("ENOLANE", `no workspace ${laneId}`);
|
||
if (lane.owner_id !== u.id) {
|
||
throw err("ENOTOWNED", `workspace ${laneId} is not ${u.username}'s`);
|
||
}
|
||
sql("UPDATE lanes SET owner_id = NULL WHERE id = ? AND owner_id = ?").run(Number(laneId), u.id);
|
||
}
|
||
|
||
/** The workspaces `userId` owns: `[{id, title, cwd}]`. */
|
||
const ownedWorkspaces = (userId) =>
|
||
sql("SELECT id, title, cwd FROM lanes WHERE owner_id = ? ORDER BY id").all(userId);
|
||
|
||
const hasAdmin = () =>
|
||
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
|
||
|
||
// ── Login sessions ──────────────────────────────────────────────────────────
|
||
|
||
/** Start a login: the cookie token, returned once, never stored. */
|
||
function startSession(userId) {
|
||
const token = crypto.randomBytes(32).toString("base64url");
|
||
const expires = new Date(Date.now() + SESSION_DAYS * 864e5).toISOString();
|
||
sql("DELETE FROM auth_sessions WHERE expires_at < ?").run(new Date().toISOString());
|
||
sql("INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)").run(
|
||
sha256(token),
|
||
userId,
|
||
expires
|
||
);
|
||
return token;
|
||
}
|
||
|
||
function endSession(token) {
|
||
if (token) sql("DELETE FROM auth_sessions WHERE token_hash = ?").run(sha256(token));
|
||
}
|
||
|
||
function cookieToken(req) {
|
||
for (const part of String(req.headers.cookie || "").split(";")) {
|
||
const eq = part.indexOf("=");
|
||
if (eq > 0 && part.slice(0, eq).trim() === COOKIE) return part.slice(eq + 1).trim() || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The logged-in user behind this request's cookie, or null. */
|
||
function userFromRequest(req) {
|
||
const token = cookieToken(req);
|
||
if (!token || token.length > 128) return null;
|
||
const row = sql(
|
||
`SELECT u.* FROM auth_sessions s JOIN users u ON u.id = s.user_id
|
||
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`
|
||
).get(sha256(token), new Date().toISOString());
|
||
return publicUser(row) || null;
|
||
}
|
||
|
||
function cookieHeader(req, token, maxAgeSec) {
|
||
const secure = req.secure || req.headers["x-forwarded-proto"] === "https";
|
||
return [
|
||
`${COOKIE}=${token}`,
|
||
"Path=/",
|
||
"HttpOnly",
|
||
"SameSite=Strict",
|
||
`Max-Age=${maxAgeSec}`,
|
||
...(secure ? ["Secure"] : []),
|
||
].join("; ");
|
||
}
|
||
|
||
const setLoginCookie = (req, res, token) =>
|
||
res.setHeader("Set-Cookie", cookieHeader(req, token, SESSION_DAYS * 86400));
|
||
const clearLoginCookie = (req, res) => res.setHeader("Set-Cookie", cookieHeader(req, "", 0));
|
||
|
||
// ── Failed-login throttle ───────────────────────────────────────────────────
|
||
|
||
// ponytail: in-memory per-IP counter, reset on restart and not shared between
|
||
// two dashboards; move it into SQLite if the server ever runs as several processes.
|
||
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
||
const FAIL_LIMIT = 10;
|
||
const failures = new Map();
|
||
|
||
/** The client's address: the socket's, or — only behind a proxy on this
|
||
* machine — the LAST X-Forwarded-For entry, the one that proxy appended (the
|
||
* earlier ones are whatever the client sent). */
|
||
function clientKey(req) {
|
||
const addr = req.socket?.remoteAddress || "?";
|
||
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) return addr;
|
||
const fwd = String(req.headers["x-forwarded-for"] || "").split(",");
|
||
return fwd[fwd.length - 1].trim() || addr;
|
||
}
|
||
|
||
// Per username too, higher: many addresses guessing one account's password.
|
||
const USER_FAIL_LIMIT = 30;
|
||
|
||
const fresh = (f) => f && Date.now() - f.since <= FAIL_WINDOW_MS;
|
||
|
||
function blocked(key, limit) {
|
||
const f = failures.get(key);
|
||
return fresh(f) && f.count >= limit;
|
||
}
|
||
|
||
/** Count an attempt as failed BEFORE it is checked, so parallel guesses all count. */
|
||
function noteAttempt(key) {
|
||
if (failures.size > 10000) {
|
||
for (const [k, v] of failures) if (!fresh(v)) failures.delete(k);
|
||
}
|
||
const f = failures.get(key);
|
||
if (!fresh(f)) failures.set(key, { since: Date.now(), count: 1 });
|
||
else f.count++;
|
||
}
|
||
|
||
// A well-formed hash no password matches (64 zero bytes), for unknown users.
|
||
const DUMMY_HASH = `scrypt$${"A".repeat(22)}==$${"A".repeat(86)}==`;
|
||
|
||
/** `{user}` for a right username + password, else throws EBADLOGIN/ETOOMANY. */
|
||
async function login(req, username, password) {
|
||
const name = String(username || "").trim();
|
||
const ipKey = clientKey(req);
|
||
const userKey = `user:${name.toLowerCase()}`;
|
||
if (blocked(ipKey, FAIL_LIMIT) || blocked(userKey, USER_FAIL_LIMIT)) {
|
||
throw err("ETOOMANY", "too many failed logins, try again later");
|
||
}
|
||
noteAttempt(ipKey);
|
||
noteAttempt(userKey);
|
||
const u = findUser(name);
|
||
// Hash even for an unknown user, so the answer time does not tell which.
|
||
const ok = await verifyPassword(String(password || ""), u?.password_hash || DUMMY_HASH);
|
||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||
failures.delete(ipKey);
|
||
failures.delete(userKey);
|
||
return { user: publicUser(u), token: startSession(u.id) };
|
||
}
|
||
|
||
/**
|
||
* Step-up check for an account change made in the dashboard: the acting
|
||
* user's OWN password again, so a login left open (or a page that rode it)
|
||
* cannot create an admin. Throttled like a login; throws EREAUTH/ETOOMANY.
|
||
*/
|
||
async function confirmPassword(user, password) {
|
||
const key = `reauth:${user.id}`;
|
||
if (blocked(key, FAIL_LIMIT)) throw err("ETOOMANY", "too many wrong passwords, try again later");
|
||
noteAttempt(key);
|
||
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(user.id);
|
||
const ok = await verifyPassword(String(password || ""), row?.password_hash || DUMMY_HASH);
|
||
if (!row || !ok) throw err("EREAUTH", "your password is wrong");
|
||
failures.delete(key);
|
||
}
|
||
|
||
// ── Pane credentials ────────────────────────────────────────────────────────
|
||
|
||
const PANE_HEADER = "x-agenthub-pane-token";
|
||
|
||
/**
|
||
* Server mode: a fresh credential for a pane the dashboard starts — the token
|
||
* goes into the pane's env (`AGENTHUB_PANE_TOKEN`, through tmux `-e`), its hash
|
||
* onto the run row. Null in local mode, where nothing needs one.
|
||
*/
|
||
function newPaneToken() {
|
||
if (!isServerMode()) return null;
|
||
const token = `ahpane_${crypto.randomBytes(24).toString("base64url")}`;
|
||
return { token, hash: sha256(token) };
|
||
}
|
||
|
||
/**
|
||
* The workspace owner a request from inside a live pane acts as: its
|
||
* `x-agenthub-pane-token` names a run that is still running in tmux, on a
|
||
* workspace someone owns, and the request came straight from this machine.
|
||
*/
|
||
function userFromPaneToken(req) {
|
||
const t = req.headers[PANE_HEADER];
|
||
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
|
||
if (!isLocalRequest(req)) return null;
|
||
const row = sql(
|
||
`SELECT r.id, r.lane_id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
|
||
).get(sha256(t));
|
||
if (!row || row.owner_id == null) return null;
|
||
// S3: only from the account that pane runs as (any local uid could post it).
|
||
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
|
||
if (!require("./tmux").hasSession(row.id)) return null;
|
||
return (
|
||
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
|
||
);
|
||
}
|
||
|
||
// ── Gates ───────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The path under `/api` as Express ROUTES it, for every prefix and id check:
|
||
* each segment percent-decoded, repeated slashes collapsed. Compare prefixes
|
||
* case-insensitively (`underPrefix`) — Express mounts and routes match
|
||
* regardless of case, so `/api/Webhooks` reaches the webhooks router.
|
||
*/
|
||
function apiPath(req) {
|
||
return String(req.path || "")
|
||
.split("/")
|
||
.map((seg) => {
|
||
try {
|
||
return decodeURIComponent(seg);
|
||
} catch {
|
||
return seg;
|
||
}
|
||
})
|
||
.join("/")
|
||
.replace(/\/{2,}/g, "/");
|
||
}
|
||
|
||
const underPrefix = (p, x) => {
|
||
const lp = p.toLowerCase();
|
||
return lp === x || lp.startsWith(x + "/");
|
||
};
|
||
|
||
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||
|
||
/** Express middleware for `/api`; a no-op in local mode. */
|
||
function authGuard(req, res, next) {
|
||
if (!isServerMode()) return next();
|
||
const p = apiPath(req);
|
||
const lp = p.toLowerCase();
|
||
if (lp === "/health" || lp.startsWith("/auth/")) return next();
|
||
if (lp === "/hooks/ingest-batch") return next(); // its own bearer token
|
||
if (underPrefix(p, "/hooks")) {
|
||
return isLocalRequest(req)
|
||
? next()
|
||
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
|
||
}
|
||
const user = userFromRequest(req) || userFromPaneToken(req);
|
||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||
req.user = user;
|
||
const under = (x) => underPrefix(p, x);
|
||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||
if (
|
||
user.role === "developer" &&
|
||
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
|
||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
||
) {
|
||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||
}
|
||
if (isRead) return next();
|
||
// Every write in server mode passes the same-origin check — the cookie is
|
||
// SameSite=Strict, which a sibling subdomain or another port on the same
|
||
// host still satisfies. A browser always sends Origin on a write; a request
|
||
// with neither Origin nor Referer is not a browser's.
|
||
const raw = req.headers.origin || req.headers.referer;
|
||
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
|
||
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
|
||
}
|
||
// An analyst writes like a developer: only their own rows (scope.js).
|
||
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
|
||
return deny(res, 403, "EADMIN", "only an admin may change this");
|
||
}
|
||
return next();
|
||
}
|
||
|
||
/**
|
||
* WebSocket upgrade check (both `/ws` and `/ws-pty/*`); true in local mode.
|
||
* A login, and — the terminal is behind this — an Origin, when sent, that is
|
||
* the request's own allowed Host.
|
||
*/
|
||
function isWebSocketLoggedIn(req) {
|
||
if (!isServerMode()) return true;
|
||
const origin = req.headers.origin;
|
||
if (origin) {
|
||
let host = "";
|
||
try {
|
||
host = new URL(origin).host.toLowerCase();
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||
}
|
||
// Which terminals a login may open is the run check's (websocket.js).
|
||
return Boolean(userFromRequest(req));
|
||
}
|
||
|
||
module.exports = {
|
||
COOKIE,
|
||
ROLES,
|
||
ADMIN_WRITE_PREFIXES,
|
||
STAFF_ONLY_PREFIXES,
|
||
apiPath,
|
||
newPaneToken,
|
||
userFromPaneToken,
|
||
PANE_HEADER,
|
||
isServerMode,
|
||
hashPassword,
|
||
verifyPassword,
|
||
listUsers,
|
||
findUser,
|
||
createUser,
|
||
setPassword,
|
||
setRole,
|
||
setDisabled,
|
||
deleteUser,
|
||
assignWorkspace,
|
||
unassignWorkspace,
|
||
ownedWorkspaces,
|
||
validateProfile,
|
||
setProfile,
|
||
validatePassword,
|
||
endSessions,
|
||
loginStats,
|
||
hasAdmin,
|
||
userFromRequest,
|
||
login,
|
||
confirmPassword,
|
||
endSession,
|
||
cookieToken,
|
||
setLoginCookie,
|
||
clearLoginCookie,
|
||
authGuard,
|
||
isWebSocketLoggedIn,
|
||
publicUser,
|
||
};
|