Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

263 lines
9.6 KiB
JavaScript

// Authentication for the appliance setup/status UI.
//
// The UI moved from a per-request `?token=` bearer to a real login layer:
// 1. First boot the appliance is in `unset` state. The operator enters the
// one-time setup token (printed on the console) and chooses a management
// password. The token is consumed by the state flip, not deleted.
// 2. Thereafter the appliance is `configured`: the operator logs in with the
// password and rides a signed session cookie.
// 3. `sudo alga-appliance-reset-admin` clears the credential, rotates the
// session secret, and re-arms a fresh token (back to `unset`).
//
// All state lives in the host volume (`/var/lib/alga-appliance`), which the
// control-plane pod RW-mounts via hostPath, so it survives pod restarts and
// reboots and is reachable by both the pod and the host-side reset CLI.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const tokenFile = process.env.ALGA_APPLIANCE_TOKEN_FILE || '/var/lib/alga-appliance/setup-token';
const credentialFile = process.env.ALGA_APPLIANCE_ADMIN_CREDENTIAL_FILE || '/var/lib/alga-appliance/admin-ui-credential.json';
const sessionSecretFile = process.env.ALGA_APPLIANCE_SESSION_SECRET_FILE || '/var/lib/alga-appliance/session-secret';
export const SESSION_COOKIE = 'alga_appliance_session';
const SESSION_TTL_SECONDS = Number(process.env.ALGA_APPLIANCE_SESSION_TTL_SECONDS || 7 * 24 * 60 * 60);
// scrypt parameters. cost N=2^14 keeps verification well under a few hundred ms
// on the appliance while staying expensive to brute-force offline.
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 64 };
// --- low-level file helpers ------------------------------------------------
function ensureDir(file) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o750 });
}
function writeSecureFile(file, content) {
ensureDir(file);
fs.writeFileSync(file, content, { mode: 0o600 });
try { fs.chmodSync(file, 0o600); } catch { /* best effort in local dev */ }
}
function readJson(file) {
if (!fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
function timingSafeEqualStr(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length || bufA.length === 0) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
// --- setup token -----------------------------------------------------------
export function generateToken() {
// 5 groups of 4 digits. Numeric groups are far easier to type from a VM
// console PIN field than mixed-case while still giving ~66 bits of entropy.
return Array.from({ length: 5 }, () => String(crypto.randomInt(0, 10_000)).padStart(4, '0')).join('-');
}
export function readSetupToken() {
try {
return fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8').trim() : '';
} catch {
return '';
}
}
// The token is shared with the control-plane pod (UID 10001) via the hostPath
// volume, so it is written world-readable (0644). It is a single-use,
// soon-inert setup token on a single-admin appliance.
export function writeSetupToken(token) {
ensureDir(tokenFile);
fs.writeFileSync(tokenFile, `${token}\n`, { mode: 0o644 });
try { fs.chmodSync(tokenFile, 0o644); } catch { /* best effort */ }
}
export function tokensMatch(provided) {
const expected = readSetupToken();
if (!expected) return false;
return timingSafeEqualStr(String(provided || '').trim(), expected);
}
// --- credential ------------------------------------------------------------
export function getCredentialState() {
const stored = readJson(credentialFile);
if (stored && stored.status === 'configured' && stored.hash && stored.salt) {
return { status: 'configured', updatedAt: stored.updatedAt || null };
}
return { status: 'unset' };
}
function hashPassword(password, salt) {
return crypto.scryptSync(password, Buffer.from(salt, 'hex'), SCRYPT_PARAMS.keylen, {
N: SCRYPT_PARAMS.N,
r: SCRYPT_PARAMS.r,
p: SCRYPT_PARAMS.p,
}).toString('hex');
}
export function setPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = hashPassword(password, salt);
writeSecureFile(credentialFile, `${JSON.stringify({
status: 'configured',
algorithm: 'scrypt',
params: SCRYPT_PARAMS,
salt,
hash,
updatedAt: new Date().toISOString(),
}, null, 2)}\n`);
}
export function verifyPassword(password) {
const stored = readJson(credentialFile);
if (!stored || stored.status !== 'configured' || !stored.hash || !stored.salt) return false;
let computed;
try {
computed = crypto.scryptSync(password, Buffer.from(stored.salt, 'hex'), (stored.params?.keylen) || SCRYPT_PARAMS.keylen, {
N: stored.params?.N || SCRYPT_PARAMS.N,
r: stored.params?.r || SCRYPT_PARAMS.r,
p: stored.params?.p || SCRYPT_PARAMS.p,
}).toString('hex');
} catch {
return false;
}
return timingSafeEqualStr(computed, stored.hash);
}
export function clearCredential() {
try {
if (fs.existsSync(credentialFile)) fs.unlinkSync(credentialFile);
} catch { /* best effort; reset CLI runs as root */ }
}
// --- session secret + signed cookie ----------------------------------------
// Read the secret from disk on every call (no in-memory cache). The file is
// tiny and the management UI is low-traffic, and this makes a reset (which
// deletes/rotates the file) take effect immediately for the running pod — old
// session cookies stop verifying the moment the secret changes.
function getSessionSecret() {
try {
const existing = fs.existsSync(sessionSecretFile) ? fs.readFileSync(sessionSecretFile, 'utf8').trim() : '';
if (existing) return existing;
} catch { /* fall through to (re)generate */ }
const secret = crypto.randomBytes(32).toString('hex');
writeSecureFile(sessionSecretFile, `${secret}\n`);
return secret;
}
export function rotateSessionSecret() {
const secret = crypto.randomBytes(32).toString('hex');
writeSecureFile(sessionSecretFile, `${secret}\n`);
return secret;
}
function signPayload(payloadB64) {
return crypto.createHmac('sha256', getSessionSecret()).update(payloadB64).digest('base64url');
}
export function createSessionToken(ttlSeconds = SESSION_TTL_SECONDS) {
const nowSeconds = Math.floor(Date.now() / 1000);
const payload = { iat: nowSeconds, exp: nowSeconds + ttlSeconds };
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${payloadB64}.${signPayload(payloadB64)}`;
}
export function verifySessionToken(token) {
if (!token || typeof token !== 'string') return false;
const dot = token.indexOf('.');
if (dot <= 0) return false;
const payloadB64 = token.slice(0, dot);
const signature = token.slice(dot + 1);
if (!timingSafeEqualStr(signature, signPayload(payloadB64))) return false;
let payload;
try {
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
} catch {
return false;
}
return typeof payload?.exp === 'number' && payload.exp > Math.floor(Date.now() / 1000);
}
function parseCookies(cookieHeader) {
const out = {};
for (const part of String(cookieHeader || '').split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
}
return out;
}
export function isAuthenticated(req) {
const cookies = parseCookies(req.headers?.cookie);
return verifySessionToken(cookies[SESSION_COOKIE]);
}
// No Secure flag: the appliance serves plain HTTP on the LAN (same exposure as
// the previous `?token=` scheme). HttpOnly + SameSite=Strict still apply.
export function sessionCookieHeader(ttlSeconds = SESSION_TTL_SECONDS) {
const token = createSessionToken(ttlSeconds);
return `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${ttlSeconds}`;
}
export function clearSessionCookieHeader() {
return `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
}
// --- auth phase for the UI -------------------------------------------------
export function authPhase(req) {
if (isAuthenticated(req)) return 'authenticated';
return getCredentialState().status === 'configured' ? 'needs-password' : 'needs-token';
}
// --- per-IP lockout --------------------------------------------------------
// In-process and intentionally simple: after MAX_FAILURES consecutive failures
// from an IP, lock with escalating backoff. Reset on success. Counters reset on
// pod restart, which is acceptable for a single-admin LAN appliance.
const MAX_FAILURES = Number(process.env.ALGA_APPLIANCE_AUTH_MAX_FAILURES || 5);
const BASE_LOCK_MS = Number(process.env.ALGA_APPLIANCE_AUTH_BASE_LOCK_MS || 60_000);
const MAX_LOCK_MS = Number(process.env.ALGA_APPLIANCE_AUTH_MAX_LOCK_MS || 15 * 60_000);
const attempts = new Map();
export function checkLockout(ip) {
const entry = attempts.get(ip);
if (!entry || !entry.lockedUntil) return { locked: false, retryAfterMs: 0 };
const remaining = entry.lockedUntil - Date.now();
if (remaining <= 0) {
entry.lockedUntil = 0;
return { locked: false, retryAfterMs: 0 };
}
return { locked: true, retryAfterMs: remaining };
}
export function registerFailure(ip) {
const entry = attempts.get(ip) || { failures: 0, lockedUntil: 0 };
entry.failures += 1;
if (entry.failures >= MAX_FAILURES) {
const over = entry.failures - MAX_FAILURES;
entry.lockedUntil = Date.now() + Math.min(MAX_LOCK_MS, BASE_LOCK_MS * 2 ** over);
}
attempts.set(ip, entry);
}
export function registerSuccess(ip) {
attempts.delete(ip);
}
// Test-only: reset in-memory lockout state.
export function _resetLockoutState() {
attempts.clear();
}