Files
BetterDesk/web-nodejs/services/authService.js
UNITRONIX 0541ad15bc Prepare 3.4.0 stable release notes and fix default-admin bootstrap.
Populate CHANGELOG [Unreleased] with curated operator highlights since main 3.3.39, and remove illegal const password reassignment in ensureDefaultAdmin.
2026-07-24 21:33:17 +02:00

1334 lines
48 KiB
JavaScript

/**
* BetterDesk Console - Auth Service
* Handles user authentication, password hashing, session management
*/
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const db = require('./database');
const config = require('../config/config');
const authLog = require('../lib/logger').child('AUTH');
const SALT_ROUNDS = 12;
// Pre-computed dummy hash for timing-safe comparison (prevents user enumeration)
const DUMMY_HASH = '$2b$12$KiXeOj5vHpJRJHGMhWzadeKfRJLvJRaRHQbMGBBdkpu.jQfXAzgWS';
const http = require('http');
const https = require('https');
// ---------------------------------------------------------------------------
// Go Server Health Cache (Phase A: Auth Delegation)
// ---------------------------------------------------------------------------
// Caches the Go server health status for 10 seconds to avoid hammering
// /api/health on every login attempt.
let _goHealthCache = { healthy: null, checkedAt: 0 };
const GO_HEALTH_CACHE_TTL = 10_000; // 10s
const GO_HEALTH_TIMEOUT = 2_000; // 2s connect+read timeout
/**
* Check whether the Go server is reachable.
* Result is cached for GO_HEALTH_CACHE_TTL ms.
* @returns {Promise<boolean>}
*/
async function checkGoServerHealth() {
const now = Date.now();
if (_goHealthCache.healthy !== null && now - _goHealthCache.checkedAt < GO_HEALTH_CACHE_TTL) {
return _goHealthCache.healthy;
}
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let healthUrl;
try {
const base = new URL(apiUrl);
healthUrl = new URL('/api/health', base.origin);
} catch (_) {
_goHealthCache = { healthy: false, checkedAt: now };
return false;
}
const mod = healthUrl.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const req = mod.request(healthUrl, {
method: 'GET',
timeout: GO_HEALTH_TIMEOUT,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
// Drain response body
res.resume();
const ok = res.statusCode >= 200 && res.statusCode < 400;
_goHealthCache = { healthy: ok, checkedAt: Date.now() };
resolve(ok);
});
req.on('error', () => {
_goHealthCache = { healthy: false, checkedAt: Date.now() };
resolve(false);
});
req.on('timeout', () => {
req.destroy();
_goHealthCache = { healthy: false, checkedAt: Date.now() };
resolve(false);
});
req.end();
});
}
/**
* Authenticate against Go server's POST /api/auth/login.
* Returns the full Go response on success, or null on failure/unreachable.
* The response shape is one of:
* { token, role, username } — credentials accepted, no 2FA
* { requires_2fa, partial_token } — 2FA required
* null — rejected or unreachable
*
* @param {string} username
* @param {string} password
* @returns {Promise<Object|null>}
*/
function authenticateViaGo(username, password) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let authUrl;
try {
const base = new URL(apiUrl);
authUrl = new URL('/api/auth/login', base.origin);
} catch (_) {
return Promise.resolve(null);
}
const body = JSON.stringify({ username, password });
const mod = authUrl.protocol === 'https:' ? https : http;
const timeout = Math.min(config.betterdeskApiTimeout || 5000, 5000);
return new Promise((resolve) => {
const req = mod.request(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode === 200) {
resolve(parsed);
return;
}
// 401/400 = Go explicitly rejected
if (res.statusCode === 401 || res.statusCode === 400) {
resolve({ rejected: true, status: res.statusCode, error: parsed.error });
return;
}
// 429 = rate limited
if (res.statusCode === 429) {
resolve({ rejected: true, rateLimited: true, error: parsed.error });
return;
}
} catch (_) { /* JSON parse error */ }
resolve(null); // unexpected response — treat as unreachable
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
/**
* Verify TOTP code via Go server's POST /api/auth/login/2fa.
* @param {string} partialToken — JWT partial token from Go login
* @param {string} code — TOTP code or recovery code
* @returns {Promise<Object|null>} — { token, role, username } or null
*/
function verifyTotpViaGo(partialToken, code) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let url;
try {
const base = new URL(apiUrl);
url = new URL('/api/auth/login/2fa', base.origin);
} catch (_) {
return Promise.resolve(null);
}
const body = JSON.stringify({ partial_token: partialToken, code });
const mod = url.protocol === 'https:' ? https : http;
const timeout = Math.min(config.betterdeskApiTimeout || 5000, 5000);
return new Promise((resolve) => {
const req = mod.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode === 200 && parsed.token) {
resolve(parsed);
return;
}
if (res.statusCode === 401 || res.statusCode === 400) {
resolve({ rejected: true, error: parsed.error });
return;
}
if (res.statusCode === 429) {
resolve({ rejected: true, rateLimited: true, error: parsed.error });
return;
}
} catch (_) { /* JSON parse error */ }
resolve(null);
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
/** Admin roles allowed to log in during emergency mode */
const EMERGENCY_ADMIN_ROLES = new Set(['admin', 'super_admin']);
// PBKDF2 parameters matching Go server's auth.HashPassword().
// Two formats are supported when verifying hashes that originated on the
// Go server (panel can fall back to those when migrating users):
// - Legacy: "hex(salt):hex(hash)" — 100_000 iter, SHA-256
// - Modern: "pbkdf2-sha256$<iter>$<salt>$<hash>" — variable iter, SHA-256
const PBKDF2_LEGACY_ITERATIONS = 100_000;
const PBKDF2_KEY_LENGTH = 32; // SHA-256 output size
const PBKDF2_DIGEST = 'sha256';
const PBKDF2_MODERN_PREFIX = 'pbkdf2-sha256$';
/**
* Detect whether a stored hash is bcrypt or PBKDF2 (Go server format).
* Accepts both the legacy "salt:hash" and modern "pbkdf2-sha256$..." forms.
*/
function isPBKDF2Hash(hash) {
if (!hash || hash.startsWith('$2b$') || hash.startsWith('$2a$')) return false;
if (hash.startsWith(PBKDF2_MODERN_PREFIX)) return true;
const parts = hash.split(':');
return parts.length === 2
&& /^[0-9a-f]{32}$/i.test(parts[0])
&& /^[0-9a-f]{64}$/i.test(parts[1]);
}
/**
* Verify a password against a PBKDF2-HMAC-SHA256 hash (Go server format).
* Supports both legacy "salt:hash" (100k iterations) and modern
* "pbkdf2-sha256$<iter>$<salt>$<hash>" formats.
*/
function verifyPBKDF2(password, stored) {
if (stored.startsWith(PBKDF2_MODERN_PREFIX)) {
const parts = stored.split('$');
// parts[0] === "pbkdf2-sha256", [1]=iter, [2]=hex-salt, [3]=hex-hash
if (parts.length !== 4) return false;
const iter = parseInt(parts[1], 10);
if (!Number.isFinite(iter) || iter <= 0 || iter > 10_000_000) return false;
let salt, expected;
try {
salt = Buffer.from(parts[2], 'hex');
expected = Buffer.from(parts[3], 'hex');
} catch (_) {
return false;
}
if (salt.length === 0 || expected.length === 0) return false;
const derived = crypto.pbkdf2Sync(password, salt, iter, expected.length, PBKDF2_DIGEST);
return derived.length === expected.length && crypto.timingSafeEqual(expected, derived);
}
const parts = stored.split(':');
if (parts.length !== 2) return false;
const salt = Buffer.from(parts[0], 'hex');
const expected = Buffer.from(parts[1], 'hex');
const derived = crypto.pbkdf2Sync(password, salt, PBKDF2_LEGACY_ITERATIONS, PBKDF2_KEY_LENGTH, PBKDF2_DIGEST);
return crypto.timingSafeEqual(expected, derived);
}
/**
* Hash a password using bcrypt
*/
async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Self-test stored admin hash; re-hash once on mismatch.
* @returns {'verified'|'recovered'|'failed'}
*/
async function verifyAdminPasswordHash(userId, passwordHash, plainPassword) {
const ok = await bcrypt.compare(plainPassword, passwordHash);
if (ok) return 'verified';
const retryHash = await hashPassword(plainPassword);
await db.updateUserPassword(userId, retryHash);
const retryOk = await bcrypt.compare(plainPassword, retryHash);
return retryOk ? 'recovered' : 'failed';
}
/**
* Verify password against hash (supports both bcrypt and PBKDF2).
* Returns { valid: boolean, needsMigration: boolean }
*/
async function verifyPasswordEx(password, hash) {
if (isPBKDF2Hash(hash)) {
return { valid: verifyPBKDF2(password, hash), needsMigration: true };
}
return { valid: await bcrypt.compare(password, hash), needsMigration: false };
}
/**
* Verify password against hash (simple boolean, backward compatible)
*/
async function verifyPassword(password, hash) {
const result = await verifyPasswordEx(password, hash);
return result.valid;
}
/**
* Cached lookup of the Go server's SSO status. Used to decide whether
* unknown-user logins should be delegated to Go (for LDAP/OIDC users who
* don't have a local account yet). Cache lasts 60s to avoid hammering the
* Go server on every login attempt. Returns { ldap, oidc, any } booleans.
*/
let _ssoStatusCache = { at: 0, value: null };
const SSO_STATUS_TTL_MS = 60_000;
function getGoSSOStatus() {
const now = Date.now();
if (_ssoStatusCache.value && (now - _ssoStatusCache.at) < SSO_STATUS_TTL_MS) {
return Promise.resolve(_ssoStatusCache.value);
}
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let statusUrl;
try {
const base = new URL(apiUrl);
statusUrl = new URL('/api/auth/sso/status', base.origin);
} catch (_) {
return Promise.resolve({ ldap: false, oidc: false, any: false });
}
const mod = statusUrl.protocol === 'https:' ? https : http;
const timeout = config.betterdeskApiTimeout || 3000;
return new Promise((resolve) => {
const req = mod.request(statusUrl, {
method: 'GET',
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
let value = { ldap: false, oidc: false, any: false };
if (res.statusCode === 200) {
try {
const parsed = JSON.parse(data);
value = {
ldap: !!parsed.ldap_enabled,
oidc: !!parsed.oidc_enabled,
any: !!parsed.any_enabled,
};
} catch (_) { /* keep defaults */ }
}
_ssoStatusCache = { at: now, value };
resolve(value);
});
});
req.on('error', () => resolve({ ldap: false, oidc: false, any: false }));
req.on('timeout', () => { req.destroy(); resolve({ ldap: false, oidc: false, any: false }); });
req.end();
});
}
const VALID_AUTH_PROVIDERS = new Set(['local', 'ldap', 'oidc']);
function normalizeAuthProvider(provider) {
const value = String(provider || 'local').trim().toLowerCase();
return VALID_AUTH_PROVIDERS.has(value) ? value : 'local';
}
function inferAuthProviderFromSSO(goResult, ssoStatus) {
if (goResult && goResult.auth_provider) {
return normalizeAuthProvider(goResult.auth_provider);
}
if (ssoStatus && ssoStatus.ldap && !ssoStatus.oidc) return 'ldap';
if (ssoStatus && ssoStatus.oidc && !ssoStatus.ldap) return 'oidc';
return 'local';
}
/** Random bcrypt hash that cannot match a real password (Issue #148). */
async function unusablePasswordHash() {
return hashPassword(crypto.randomBytes(32).toString('hex'));
}
function isExternalAuthProvider(provider) {
const p = normalizeAuthProvider(provider);
return p === 'ldap' || p === 'oidc';
}
function isExternalAuthResult(goResult) {
if (!goResult) return false;
return isExternalAuthProvider(goResult.auth_provider);
}
function authFailure(code) {
return { __authFailure: code };
}
function isAuthFailure(result) {
return !!(result && typeof result === 'object' && result.__authFailure);
}
async function syncLocalUserFromGoResult(localUser, goResult, password, ssoStatus) {
if (!localUser || !goResult) return localUser;
const authProvider = inferAuthProviderFromSSO(goResult, ssoStatus);
const role = goResult.role || localUser.role;
const sync = {};
if (authProvider !== normalizeAuthProvider(localUser.auth_provider)) {
sync.authProvider = authProvider;
}
if (role && role !== localUser.role) {
sync.role = role;
}
// Never store IdP passwords in auth.db for LDAP/OIDC accounts.
if (password && authProvider === 'local') {
sync.passwordHash = await hashPassword(password);
} else if (authProvider !== 'local'
&& authProvider !== normalizeAuthProvider(localUser.auth_provider)) {
sync.passwordHash = await unusablePasswordHash();
}
if (Object.keys(sync).length === 0) {
return localUser;
}
await db.syncUserFromGo(localUser.id, sync);
authLog.info(`[AUTH] Synced '${localUser.username}' from Go (provider=${authProvider}, role=${role})`);
return {
...localUser,
role: sync.role || localUser.role,
auth_provider: sync.authProvider || localUser.auth_provider || 'local',
};
}
async function provisionLocalUserFromGo(username, password, goResult, ssoStatus) {
const authProvider = inferAuthProviderFromSSO(goResult, ssoStatus);
const role = goResult.role || 'viewer';
const passwordHash = authProvider === 'local'
? await hashPassword(password)
: await unusablePasswordHash();
let user = await db.getUserByUsername(username);
if (user) {
return syncLocalUserFromGoResult(user, goResult, null, ssoStatus);
}
try {
await db.createUser(username, passwordHash, role, authProvider);
} catch (err) {
// Shared PostgreSQL users table: Go may have created the row first.
user = await db.getUserByUsername(username);
if (!user) throw err;
return syncLocalUserFromGoResult(user, goResult, null, ssoStatus);
}
user = await db.getUserByUsername(username);
if (user && (user.auth_provider !== authProvider || user.role !== role)) {
await db.syncUserFromGo(user.id, { authProvider, role });
user = { ...user, auth_provider: authProvider, role };
}
return user;
}
/**
* Build a successful authenticate() response from a local user row.
*/
function authSuccessFromUser(user, goResult) {
if (user.role === 'pro') {
return null;
}
if (user.totp_enabled) {
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || null,
totpRequired: true,
goPartialToken: goResult && goResult.partial_token,
};
}
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || null,
totpRequired: !!(goResult && goResult.requires2fa),
};
}
/**
* Fallback authentication against Go server's /api/auth/login endpoint.
* Used when local (Node.js) auth fails — the Go server may have a different
* password hash (e.g., after fresh install race condition, or manual password
* change on Go server side).
* Returns { role, auth_provider } on success, or null on failure.
*/
function tryGoServerAuth(username, password) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let authUrl;
try {
const base = new URL(apiUrl);
authUrl = new URL('/api/auth/login', base.origin);
} catch (_) {
return Promise.resolve(null);
}
const body = JSON.stringify({ username, password });
const mod = authUrl.protocol === 'https:' ? https : http;
const timeout = config.betterdeskApiTimeout || 3000;
return new Promise((resolve) => {
const req = mod.request(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 200) {
try {
const parsed = JSON.parse(data);
// Go server returns { token, role, username, auth_provider } on success
if (parsed.token && parsed.role) {
resolve({
role: parsed.role,
auth_provider: parsed.auth_provider,
});
return;
}
// 2FA required — credentials are valid but need second factor
if (parsed.requires_2fa) {
resolve({ role: 'admin', requires2fa: true });
return;
}
} catch (_) { /* JSON parse error */ }
}
resolve(null);
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
/**
* LDAP-only credential check against Go (no login, no user creation).
* Used to detect username collisions when a local account already exists.
*/
function tryLdapVerifyOnGo(username, password) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let verifyUrl;
try {
const base = new URL(apiUrl);
verifyUrl = new URL('/api/auth/ldap/verify', base.origin);
} catch (_) {
return Promise.resolve(false);
}
const body = JSON.stringify({ username, password });
const mod = verifyUrl.protocol === 'https:' ? https : http;
const timeout = config.betterdeskApiTimeout || 3000;
return new Promise((resolve) => {
const req = mod.request(verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 200) {
try {
const parsed = JSON.parse(data);
resolve(!!parsed.authenticated);
return;
} catch (_) { /* JSON parse error */ }
}
resolve(false);
});
});
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.write(body);
req.end();
});
}
/**
* Authenticate user with username and password.
*
* Provider-bound flow (Issue #148, mirrors Go server):
* - oidc accounts: password login rejected
* - ldap accounts: always authenticate via Go (role/provider synced each login)
* - local accounts: local password only; never fall through to LDAP
* - unknown users: provision via Go when SSO or BETTERDESK_AUTH_AUTOCREATE is on
*
* Returns user object with totpRequired flag if 2FA is enabled.
*/
async function authenticate(username, password) {
// Safeguard: reject empty username immediately (Issue #104)
if (!username || typeof username !== 'string' || username.trim() === '') {
authLog.info(`[AUTH] Rejected authenticate() with empty/invalid username: ${JSON.stringify(username)}`);
return null;
}
const ssoStatus = await getGoSSOStatus();
let user = await db.getUserByUsername(username);
if (user) {
const provider = normalizeAuthProvider(user.auth_provider);
if (provider === 'oidc') {
authLog.info(`[AUTH] Login failed: '${username}' is an SSO account (password login not allowed)`);
return null;
}
if (provider === 'ldap') {
const goResult = await tryGoServerAuth(username, password);
if (!goResult) {
authLog.info(`[AUTH] Login failed: LDAP credentials rejected for '${username}'`);
return null;
}
authLog.info(`[AUTH] Go server accepted LDAP login for '${username}' — syncing local account`);
user = await syncLocalUserFromGoResult(user, goResult, null, ssoStatus);
const result = authSuccessFromUser(user, goResult);
if (!result) return null;
if (!result.totpRequired) {
await db.updateLastLogin(user.id);
}
return result;
}
// Local account — verify password locally only (no LDAP fallthrough).
const hashType = isPBKDF2Hash(user.password_hash) ? 'PBKDF2'
: (user.password_hash && user.password_hash.startsWith('$2')) ? 'bcrypt'
: 'unknown';
authLog.info(`[AUTH] Verifying password for '${username}' (hash type: ${hashType}, length: ${(user.password_hash || '').length})`);
const { valid, needsMigration } = await verifyPasswordEx(password, user.password_hash);
if (!valid) {
// Go may hold a newer hash after a password change on the server side.
const goResult = await tryGoServerAuth(username, password);
if (goResult) {
// Collision hardening: never auto-convert a local account to LDAP/OIDC.
// If Go accepted the password via an external provider, treat this as a
// username collision and require admin intervention (Issue #148 follow-up).
if (isExternalAuthResult(goResult)) {
authLog.info(`[AUTH] Login blocked: username collision for local '${username}' (Go provider=${normalizeAuthProvider(goResult.auth_provider)})`);
return authFailure('username_collision');
}
authLog.info(`[AUTH] Go server accepted password for local '${username}' — syncing hash`);
user = await syncLocalUserFromGoResult(user, goResult, password, ssoStatus);
} else if (ssoStatus.ldap && await tryLdapVerifyOnGo(username, password)) {
// Go /api/auth/login skips LDAP for local-bound accounts; probe LDAP
// directly so users get a collision message instead of "wrong password".
authLog.info(`[AUTH] Login blocked: username collision for local '${username}' (LDAP credentials valid)`);
return authFailure('username_collision');
} else {
authLog.info(`[AUTH] Login failed: password mismatch for '${username}' (hash type: ${hashType})`);
return null;
}
} else {
authLog.info(`[AUTH] Login successful for '${username}'`);
if (needsMigration) {
try {
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(user.id, bcryptHash);
authLog.info(`[AUTH] Migrated password hash from PBKDF2 to bcrypt for user: ${username}`);
} catch (err) {
authLog.warn('[AUTH] Failed to migrate password hash for user id', user.id, ':', err.message);
}
}
}
const result = authSuccessFromUser(user, null);
if (!result) return null;
if (!result.totpRequired) {
await db.updateLastLogin(user.id);
}
return result;
}
// Unknown user — timing-safe dummy compare, then Go provisioning.
await bcrypt.compare(password, DUMMY_HASH);
const autoCreateEnv = process.env.BETTERDESK_AUTH_AUTOCREATE === 'true';
const autoCreateEnabled = autoCreateEnv || ssoStatus.any;
if (autoCreateEnabled) {
const goResult = await tryGoServerAuth(username, password);
if (goResult) {
const reason = autoCreateEnv ? 'BETTERDESK_AUTH_AUTOCREATE=true'
: ssoStatus.ldap && ssoStatus.oidc ? 'LDAP+OIDC enabled on Go server'
: ssoStatus.ldap ? 'LDAP enabled on Go server'
: 'OIDC enabled on Go server';
authLog.info(`[AUTH] Go server accepted credentials for '${username}' — provisioning local user (${reason})`);
const created = await provisionLocalUserFromGo(username, password, goResult, ssoStatus);
if (created) {
const result = authSuccessFromUser(created, goResult);
if (!result) return null;
if (!result.totpRequired) {
await db.updateLastLogin(created.id);
}
return result;
}
}
}
authLog.info(`[AUTH] Login failed: user '${username}' not found in database`);
return null;
}
/**
* Ensure a user record exists in the local database for session storage.
* If the user doesn't exist locally, create it. If it exists, update the
* password hash (for emergency fallback) and role.
*
* @param {string} username
* @param {string} password — plaintext (available only during login)
* @param {string} role
* @returns {Promise<Object|null>} — local user record
*/
async function ensureLocalUserFromGo(username, password, role, authProvider = 'local') {
let localUser = await db.getUserByUsername(username);
const provider = normalizeAuthProvider(authProvider);
// Sync password hash for emergency fallback (only for admin roles)
const shouldSyncHash = EMERGENCY_ADMIN_ROLES.has(role) && provider === 'local';
if (!localUser) {
// Auto-create local user from Go server data
try {
const bcryptHash = provider === 'local'
? await hashPassword(password)
: await unusablePasswordHash();
await db.createUser(username, bcryptHash, role, provider);
localUser = await db.getUserByUsername(username);
if (localUser) {
authLog.info(`[AUTH] Auto-created local user '${username}' (role: ${role}, provider: ${provider}) for session storage`);
}
} catch (err) {
authLog.warn(`[AUTH] Failed to auto-create local user '${username}': ${err.message}`);
}
} else if (shouldSyncHash) {
// Update local hash so emergency fallback always has current password
try {
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(localUser.id, bcryptHash);
} catch (err) {
authLog.warn(`[AUTH] Failed to sync local hash for '${username}': ${err.message}`);
}
// Sync role if changed on Go side
if (localUser.role !== role) {
try {
await db.updateUserRole(localUser.id, role);
localUser.role = role;
} catch (_) { /* updateUserRole may not exist in all adapters */ }
}
}
return localUser;
}
/**
* Check if the installation scripts requested a forced password update.
* Two mechanisms: sentinel file (.force_password_update) or env var FORCE_PASSWORD_UPDATE.
* Returns true if force update is requested, and removes the sentinel file.
*/
function checkForcePasswordUpdate() {
// Env var (Docker installs set FORCE_PASSWORD_UPDATE=true in compose)
if (process.env.FORCE_PASSWORD_UPDATE === 'true') {
authLog.info(`[AUTH] FORCE_PASSWORD_UPDATE env var detected — will force admin password update`);
// Clear the env var so it only takes effect once per startup
delete process.env.FORCE_PASSWORD_UPDATE;
return true;
}
// Sentinel file (native installs create .force_password_update in data dir)
const sentinelPath = path.join(config.dataDir || '.', '.force_password_update');
try {
if (fs.existsSync(sentinelPath)) {
authLog.info(`[AUTH] .force_password_update sentinel file detected — will force admin password update`);
fs.unlinkSync(sentinelPath);
return true;
}
} catch (_) { /* ignore fs errors */ }
return false;
}
/**
* Try to read the admin password from the Go server's .admin_credentials file.
* The Go server writes this file on first run (main.go) when it auto-generates
* a random admin password. Format:
* Admin Username: admin
* Admin Password: <plaintext>
* ...
* Returns the password string or null if file is missing/unreadable.
*/
function readAdminCredentialsFile() {
// Search multiple candidate directories (Go server's DB dir may differ from keysPath)
const candidates = [
config.dataDir,
config.keysPath,
path.join(config.keysPath, 'data'),
'/opt/betterdesk',
'/opt/betterdesk/data',
'/opt/rustdesk',
'/opt/rustdesk/data',
];
if (process.platform === 'win32') {
candidates.push('C:\\BetterDesk', 'C:\\BetterDesk\\data',
'C:\\RustDesk', 'C:\\RustDesk\\data');
}
// Docker: also check /app/data if not already covered
if (fs.existsSync('/.dockerenv') || process.env.DOCKER === 'true') {
if (!candidates.includes('/app/data')) candidates.push('/app/data');
}
for (const dir of candidates) {
if (!dir) continue;
const filePath = path.join(dir, '.admin_credentials');
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^Admin Password:\s*(.+)$/m);
if (match && match[1].trim()) {
authLog.info(`[AUTH] Read admin password from ${filePath}`);
return match[1].trim();
}
}
} catch (_) { /* permission denied or read error — try next */ }
}
return null;
}
/**
* Create default admin user if no users exist.
* In PostgreSQL mode, the Go server may have already created the admin user
* with a PBKDF2 hash. In that case, we migrate the hash to bcrypt format
* using the password from DEFAULT_ADMIN_PASSWORD env var.
*/
async function ensureDefaultAdmin() {
const defaultUsername = process.env.DEFAULT_ADMIN_USERNAME || 'admin';
let defaultPassword = process.env.DEFAULT_ADMIN_PASSWORD || '';
// If no password from env, try reading from Go server's .admin_credentials file.
// The Go server writes this file on first run when it generates a random password.
// Format: "Admin Username: admin\nAdmin Password: <password>\n..."
if (!defaultPassword) {
defaultPassword = readAdminCredentialsFile() || '';
}
const forceUpdate = checkForcePasswordUpdate();
authLog.info(`[AUTH] ensureDefaultAdmin: checking for existing users...`);
if (await db.hasUsers()) {
// Users exist — check if the admin's hash needs migration from PBKDF2 to bcrypt.
// This handles the case where the Go server created the user first (PostgreSQL shared DB).
if (defaultPassword) {
const admin = await db.getUserByUsername(defaultUsername);
if (admin && isPBKDF2Hash(admin.password_hash)) {
authLog.info(`[AUTH] Found admin user with PBKDF2 hash (created by Go server). Migrating to bcrypt...`);
if (verifyPBKDF2(defaultPassword, admin.password_hash)) {
const bcryptHash = await hashPassword(defaultPassword);
await db.updateUserPassword(admin.id, bcryptHash);
authLog.info(`[AUTH] Admin password hash migrated from PBKDF2 to bcrypt successfully`);
} else {
authLog.warn(`[AUTH] DEFAULT_ADMIN_PASSWORD does not match existing PBKDF2 hash — skipping migration`);
}
} else if (admin) {
const hashType = (admin.password_hash || '').startsWith('$2') ? 'bcrypt' : 'unknown';
// Only force password write on explicit fresh-install sentinel (issue #158).
// Routine updates must never change users.password_hash in auth.db / PostgreSQL.
if (forceUpdate && defaultPassword) {
authLog.info(`[AUTH] Force password update requested — updating admin password`);
const bcryptHash = await hashPassword(defaultPassword);
await db.updateUserPassword(admin.id, bcryptHash);
authLog.info(`[AUTH] Admin password hash force-updated to match DEFAULT_ADMIN_PASSWORD`);
} else {
authLog.info(`[AUTH] Default admin account exists (${hashType}) — password unchanged`);
}
}
} else {
authLog.info(`[AUTH] Users exist, no DEFAULT_ADMIN_PASSWORD set — skipping admin check`);
}
return false;
}
// No users at all — create the default admin.
// If no password from env or credential file, retry reading multiple times.
// The Go server may still be starting up and hasn't written .admin_credentials yet.
if (!defaultPassword) {
const retryDelays = [2000, 3000, 5000, 5000, 10000]; // 5 retries: 2s, 3s, 5s, 5s, 10s (total 25s max)
for (let i = 0; i < retryDelays.length; i++) {
authLog.info(`[AUTH] No admin password found. Waiting for Go server (attempt ${i + 1}/${retryDelays.length})...`);
await new Promise(resolve => setTimeout(resolve, retryDelays[i]));
defaultPassword = readAdminCredentialsFile() || '';
if (defaultPassword) {
authLog.info(`[AUTH] Found admin password from Go server on retry ${i + 1}`);
break;
}
}
}
const password = defaultPassword || require('crypto').randomBytes(16).toString('hex');
// If we generated the password (not from env or Go server), write it to a shared location
// so it can be discovered by users or other services.
if (!defaultPassword) {
const credsPath = path.join(config.dataDir, '.admin_credentials');
try {
const credsContent = `Admin Username: ${defaultUsername}\nAdmin Password: ${password}\nGenerated by: BetterDesk Console (Node.js)\nTimestamp: ${new Date().toISOString()}\n`;
fs.writeFileSync(credsPath, credsContent, { mode: 0o600 });
authLog.info(`[AUTH] Wrote generated admin credentials to ${credsPath}`);
} catch (e) {
authLog.warn(`[AUTH] Could not write .admin_credentials to ${credsPath}: ${e.message}`);
}
}
const hash = await hashPassword(password);
await db.createUser(defaultUsername, hash, 'admin');
// Verify the hash was stored correctly (self-test)
const created = await db.getUserByUsername(defaultUsername);
let verifyStatus = 'missing';
if (created) {
verifyStatus = await verifyAdminPasswordHash(created.id, created.password_hash, password);
} else {
authLog.error('[AUTH] CRITICAL: createUser succeeded but getUserByUsername returned null for default admin');
}
switch (verifyStatus) {
case 'verified':
authLog.info('[AUTH] Default admin user created and verified successfully');
break;
case 'recovered':
authLog.info(`[AUTH] Admin password self-test recovered after re-hash`);
break;
case 'failed':
authLog.error(`[AUTH] CRITICAL: Admin password self-test STILL FAILING — bcrypt may be broken`);
break;
default:
break;
}
if (!defaultPassword) {
authLog.info(`[AUTH] Generated admin credentials written to ${path.join(config.dataDir, '.admin_credentials')} — change password immediately`);
} else {
authLog.info('[AUTH] Default admin account created from configured credentials');
}
return true;
}
/**
* Change user password
*/
async function changePassword(userId, currentPassword, newPassword) {
const user = await db.getUserById(userId);
if (!user) {
return { success: false, error: 'User not found' };
}
const valid = await verifyPassword(currentPassword, user.password_hash);
if (!valid) {
return { success: false, error: 'Current password is incorrect' };
}
// Validate new password strength
if (newPassword.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
const newHash = await hashPassword(newPassword);
await db.updateUserPassword(userId, newHash);
return { success: true };
}
/**
* Validate password strength
*/
function validatePasswordStrength(password) {
const result = {
score: 0,
feedback: []
};
if (password.length >= 8) result.score += 1;
else result.feedback.push('Use at least 8 characters');
if (password.length >= 12) result.score += 1;
if (/[a-z]/.test(password)) result.score += 1;
else result.feedback.push('Add lowercase letters');
if (/[A-Z]/.test(password)) result.score += 1;
else result.feedback.push('Add uppercase letters');
if (/[0-9]/.test(password)) result.score += 1;
else result.feedback.push('Add numbers');
if (/[^a-zA-Z0-9]/.test(password)) result.score += 1;
else result.feedback.push('Add special characters');
result.strength = result.score <= 2 ? 'weak' : result.score <= 4 ? 'medium' : 'strong';
return result;
}
// ==================== TOTP (2FA) Functions ====================
/**
* Generate TOTP secret and QR code for user setup
*/
async function generateTotpSetup(userId) {
const user = await db.getUserById(userId);
if (!user) {
return { success: false, error: 'User not found' };
}
// Generate secret
const secret = authenticator.generateSecret();
// Save secret to DB (not yet enabled)
await db.saveTotpSecret(userId, secret);
// Generate otpauth URI
const otpauthUrl = authenticator.keyuri(user.username, 'BetterDesk Console', secret);
// Generate QR code as data URL
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
});
return {
success: true,
secret,
qrCode: qrCodeDataUrl,
otpauthUrl
};
}
/**
* Verify TOTP code and enable 2FA
*/
async function verifyAndEnableTotp(userId, token) {
const user = await db.getUserById(userId);
if (!user || !user.totp_secret) {
return { success: false, error: 'TOTP not set up' };
}
// Verify the token against the stored secret
const isValid = authenticator.verify({
token,
secret: user.totp_secret
});
if (!isValid) {
return { success: false, error: 'Invalid verification code' };
}
// Generate recovery codes
const recoveryCodes = generateRecoveryCodes(8);
// Enable TOTP
await db.enableTotp(userId, recoveryCodes);
return {
success: true,
recoveryCodes
};
}
/**
* Verify TOTP code during login
*/
async function verifyTotpCode(userId, token) {
const user = await db.getUserById(userId);
if (!user || !user.totp_enabled || !user.totp_secret) {
return false;
}
const isValid = authenticator.verify({
token,
secret: user.totp_secret
});
return isValid;
}
/**
* Verify recovery code during login
*/
async function verifyRecoveryCode(userId, code) {
const user = await db.getUserById(userId);
if (!user || !user.totp_enabled || !user.totp_recovery_codes) {
return false;
}
let codes;
try {
codes = JSON.parse(user.totp_recovery_codes);
} catch (e) {
return false;
}
const normalizedCode = code.trim().toUpperCase();
const index = codes.findIndex(c => c.toUpperCase() === normalizedCode);
if (index === -1) {
return false;
}
// Remove used code
codes.splice(index, 1);
await db.useRecoveryCode(userId, codes);
return true;
}
/**
* Disable TOTP for user
*/
async function disableTotp(userId) {
await db.disableTotp(userId);
return { success: true };
}
/**
* Check if user has TOTP enabled
*/
async function isTotpEnabled(userId) {
const user = await db.getUserById(userId);
return user ? !!user.totp_enabled : false;
}
/**
* Generate random recovery codes
*/
function generateRecoveryCodes(count = 8) {
const codes = [];
for (let i = 0; i < count; i++) {
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
codes.push(code.slice(0, 4) + '-' + code.slice(4));
}
return codes;
}
// ==================== RustDesk Client API Token Functions ====================
const TOKEN_EXPIRY_DAYS = () => parseInt(process.env.API_TOKEN_EXPIRY_DAYS, 10) || 7;
const MAX_FAILED_ATTEMPTS = parseInt(process.env.API_MAX_FAILED_ATTEMPTS, 10) || 10;
const LOCKOUT_MINUTES = parseInt(process.env.API_LOCKOUT_MINUTES, 10) || 15;
const IP_RATE_LIMIT = parseInt(process.env.API_IP_RATE_LIMIT, 10) || 30;
const ATTEMPT_WINDOW_MINUTES = parseInt(process.env.API_ATTEMPT_WINDOW, 10) || 15;
/**
* Generate a secure access token for RustDesk client
* Token format: 64 hex chars (256 bits of entropy)
*/
async function generateAccessToken(userId, clientId, clientUuid, ipAddress) {
// Revoke old tokens for the same client device
await db.revokeUserClientTokens(userId, clientId, clientUuid);
// Generate cryptographically secure token
const token = crypto.randomBytes(32).toString('hex');
// Calculate expiry
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_DAYS() * 24 * 60 * 60 * 1000)
.toISOString().replace('T', ' ').replace('Z', '');
await db.createAccessToken(token, userId, clientId, clientUuid, expiresAt, ipAddress);
return token;
}
/**
* Validate an access token and return associated user
*/
async function validateAccessToken(token) {
if (!token || typeof token !== 'string' || token.length !== 64) {
return null;
}
const tokenRecord = await db.getAccessToken(token);
if (!tokenRecord) {
return null;
}
const user = await db.getUserById(tokenRecord.user_id);
if (!user) {
return null;
}
// Update last_used
await db.touchAccessToken(token);
return {
id: user.id,
username: user.username,
role: user.role,
clientId: tokenRecord.client_id,
clientUuid: tokenRecord.client_uuid
};
}
/**
* Revoke all tokens for a user+client during logout
*/
async function revokeClientTokens(userId, clientId, clientUuid) {
if (clientId && clientUuid) {
await db.revokeUserClientTokens(userId, clientId, clientUuid);
} else {
await db.revokeAllUserTokens(userId);
}
}
// ==================== Brute-Force Protection ====================
/**
* Check if login should be blocked (account lockout or IP rate limit)
* Returns { blocked: boolean, reason: string, retryAfter: number }
*/
async function checkBruteForce(username, ipAddress) {
// Check account lockout
if (username) {
const lockout = await db.getAccountLockout(username);
if (lockout) {
const retryAfter = Math.ceil(
(new Date(lockout.locked_until + 'Z').getTime() - Date.now()) / 1000
);
return {
blocked: true,
reason: 'Account temporarily locked due to too many failed attempts',
retryAfter: Math.max(retryAfter, 1)
};
}
}
// Check IP rate limiting
if (ipAddress) {
const ipAttempts = await db.countRecentFailedAttemptsFromIp(ipAddress, ATTEMPT_WINDOW_MINUTES);
if (ipAttempts >= IP_RATE_LIMIT) {
return {
blocked: true,
reason: 'Too many failed attempts from this IP address',
retryAfter: ATTEMPT_WINDOW_MINUTES * 60
};
}
}
return { blocked: false };
}
/**
* Record a login attempt and potentially lock account
*/
async function recordAttempt(username, ipAddress, success) {
await db.recordLoginAttempt(username, ipAddress, success);
if (success) {
// Clear lockout on successful login
await db.clearAccountLockout(username);
return;
}
// Check if we need to lock the account
const failedCount = await db.countRecentFailedAttempts(username, ATTEMPT_WINDOW_MINUTES);
if (failedCount >= MAX_FAILED_ATTEMPTS) {
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000)
.toISOString().replace('T', ' ').replace('Z', '');
await db.lockAccount(username, lockedUntil, failedCount);
}
}
/**
* Run periodic housekeeping (expired tokens, old attempts)
*/
async function cleanupHousekeeping() {
try {
await db.cleanupExpiredTokens();
await db.cleanupOldLoginAttempts();
} catch (err) {
authLog.error('Housekeeping error:', err.message);
}
}
module.exports = {
hashPassword,
verifyPassword,
authenticate,
authFailure,
isAuthFailure,
ensureDefaultAdmin,
changePassword,
validatePasswordStrength,
// Go server delegation (Phase A)
checkGoServerHealth,
authenticateViaGo,
verifyTotpViaGo,
// TOTP
generateTotpSetup,
verifyAndEnableTotp,
verifyTotpCode,
verifyRecoveryCode,
disableTotp,
isTotpEnabled,
// RustDesk Client API tokens
generateAccessToken,
validateAccessToken,
revokeClientTokens,
// Brute-force protection
checkBruteForce,
recordAttempt,
cleanupHousekeeping,
// Issue #148 — exported for unit tests
normalizeAuthProvider,
inferAuthProviderFromSSO,
isExternalAuthProvider,
isExternalAuthResult,
};