/** * 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 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'); // PBKDF2 parameters matching Go server's auth.HashPassword() const PBKDF2_ITERATIONS = 100_000; const PBKDF2_KEY_LENGTH = 32; // SHA-256 output size const PBKDF2_DIGEST = 'sha256'; /** * Detect whether a stored hash is bcrypt or PBKDF2 (Go server format). * Go format: "hex_salt:hex_derived_key" (32-char salt + ":" + 64-char key) * bcrypt format: "$2b$..." or "$2a$..." */ function isPBKDF2Hash(hash) { if (!hash || hash.startsWith('$2b$') || hash.startsWith('$2a$')) return false; 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). * Format: "hex(salt):hex(derived_key)" with 100,000 iterations, SHA-256. */ function verifyPBKDF2(password, stored) { 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_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); } /** * 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; } /** * 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: string } 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 } on success if (parsed.token && parsed.role) { resolve({ role: parsed.role }); 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(); }); } /** * Authenticate user with username and password. * Supports both bcrypt (Node.js native) and PBKDF2 (Go server) hash formats. * When a PBKDF2 hash is verified successfully, it is auto-migrated to bcrypt * so subsequent logins do not need the PBKDF2 code path. * 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() === '') { console.log(`[AUTH] Rejected authenticate() with empty/invalid username: ${JSON.stringify(username)}`); return null; } const user = await db.getUserByUsername(username); if (!user) { // Timing-safe: do a real hash comparison to prevent user enumeration await bcrypt.compare(password, DUMMY_HASH); // Fallback: user may exist on Go server but not in local Node.js auth.db const goResult = await tryGoServerAuth(username, password); if (goResult) { console.log(`[AUTH] Go server accepted credentials for '${username}' — creating local user`); const bcryptHash = await hashPassword(password); await db.createUser(username, bcryptHash, goResult.role || 'admin'); const created = await db.getUserByUsername(username); if (created) { await db.updateLastLogin(created.id); return { id: created.id, username: created.username, role: created.role, totpRequired: false, }; } } console.log(`[AUTH] Login failed: user '${username}' not found in database`); return null; } // Diagnostic: log hash format to help debug password issues const hashType = isPBKDF2Hash(user.password_hash) ? 'PBKDF2' : (user.password_hash && user.password_hash.startsWith('$2')) ? 'bcrypt' : 'unknown'; console.log(`[AUTH] Verifying password for '${username}' (hash type: ${hashType}, length: ${(user.password_hash || '').length})`); const { valid, needsMigration } = await verifyPasswordEx(password, user.password_hash); if (!valid) { // Fallback: try Go server auth — password may have been changed on Go side const goResult = await tryGoServerAuth(username, password); if (goResult) { console.log(`[AUTH] Go server accepted password for '${username}' — syncing local hash`); const bcryptHash = await hashPassword(password); await db.updateUserPassword(user.id, bcryptHash); // Fall through to TOTP check and normal success path } else { console.log(`[AUTH] Login failed: password mismatch for '${username}' (hash type: ${hashType})`); return null; } } else if (valid) { console.log(`[AUTH] Login successful for '${username}'`); } // Auto-migrate PBKDF2 hash to bcrypt for future logins if (valid && needsMigration) { try { const bcryptHash = await hashPassword(password); await db.updateUserPassword(user.id, bcryptHash); console.log(`[AUTH] Migrated password hash from PBKDF2 to bcrypt for user: ${username}`); } catch (err) { console.warn(`[AUTH] Failed to migrate password hash for ${username}:`, err.message); } } // Check if TOTP is enabled if (user.totp_enabled) { return { id: user.id, username: user.username, role: user.role, totpRequired: true }; } // Update last login await db.updateLastLogin(user.id); return { id: user.id, username: user.username, role: user.role, totpRequired: false }; } /** * 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') { console.log(`[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)) { console.log(`[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: * ... * 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()) { console.log(`[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(); console.log(`[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)) { console.log(`[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); console.log(`[AUTH] Admin password hash migrated from PBKDF2 to bcrypt successfully`); } else { console.warn(`[AUTH] DEFAULT_ADMIN_PASSWORD does not match existing PBKDF2 hash — skipping migration`); } } else if (admin) { // Admin exists with bcrypt hash — check if password matches. // Force update when the install script requested it (reinstallation), // or when admin has never logged in (fresh install with stale auth.db). const hashType = (admin.password_hash || '').startsWith('$2') ? 'bcrypt' : 'unknown'; if (forceUpdate) { console.log(`[AUTH] Force password update requested — updating admin password regardless of last_login`); const bcryptHash = await hashPassword(defaultPassword); await db.updateUserPassword(admin.id, bcryptHash); console.log(`[AUTH] Admin password hash force-updated to match DEFAULT_ADMIN_PASSWORD`); } else if (!admin.last_login) { const matches = await verifyPassword(defaultPassword, admin.password_hash); if (!matches) { console.warn(`[AUTH] DEFAULT_ADMIN_PASSWORD does not match stored ${hashType} hash for '${defaultUsername}' (never logged in). Updating hash...`); const bcryptHash = await hashPassword(defaultPassword); await db.updateUserPassword(admin.id, bcryptHash); console.log(`[AUTH] Admin password hash updated to match DEFAULT_ADMIN_PASSWORD`); } else { console.log(`[AUTH] Admin user '${defaultUsername}' exists (${hashType}), password matches, never logged in`); } } else { console.log(`[AUTH] Admin user '${defaultUsername}' exists (${hashType}), has logged in before — not touching password`); } } } else { console.log(`[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++) { console.log(`[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) { console.log(`[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 }); console.log(`[AUTH] Wrote generated admin credentials to ${credsPath}`); } catch (e) { console.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); if (created) { const selfTest = await bcrypt.compare(password, created.password_hash); if (selfTest) { console.log(`[AUTH] Admin user '${defaultUsername}' created and verified successfully`); } else { console.error(`[AUTH] CRITICAL: Admin password self-test FAILED! Hash may be corrupted. Re-hashing...`); const retryHash = await hashPassword(password); await db.updateUserPassword(created.id, retryHash); const retryTest = await bcrypt.compare(password, retryHash); console.log(`[AUTH] Re-hash result: ${retryTest ? 'OK' : 'STILL FAILING — bcrypt may be broken'}`); } } else { console.error(`[AUTH] CRITICAL: createUser succeeded but getUserByUsername returned null for '${defaultUsername}'`); } if (!defaultPassword) { console.log(`Generated admin password: ${password}`); } console.log('IMPORTANT: Change the default password immediately!'); 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) { console.error('Housekeeping error:', err.message); } } module.exports = { hashPassword, verifyPassword, authenticate, ensureDefaultAdmin, changePassword, validatePasswordStrength, // TOTP generateTotpSetup, verifyAndEnableTotp, verifyTotpCode, verifyRecoveryCode, disableTotp, isTotpEnabled, // RustDesk Client API tokens generateAccessToken, validateAccessToken, revokeClientTokens, // Brute-force protection checkBruteForce, recordAttempt, cleanupHousekeeping };