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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
263 lines
9.6 KiB
JavaScript
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();
|
|
}
|