Files
BetterDesk/web-nodejs/services/authService.js
T
UNITRONIX c5970657e7 feat(i18n): expose all panel languages in selector
Render language choices dynamically from loaded locale files instead of hardcoding EN/PL. Add selectors on the navbar, login screen, and desktop login screen so users can choose a language before or after authentication.

Persist the selected language in the browser via cookie/localStorage and on authenticated accounts via users.preferred_language. Merge client translations with the default locale fallback so incomplete language files do not surface raw keys in the UI.
2026-05-03 02:02:15 +02:00

795 lines
28 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 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,
preferred_language: created.preferred_language || null,
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,
preferred_language: user.preferred_language || null,
totpRequired: true
};
}
// Update last login
await db.updateLastLogin(user.id);
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || null,
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: <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()) {
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
};