Files
BetterDesk/web-nodejs/services/userSync.js
T
UNITRONIX 74300c916c Fix user recovery after update
Reported-by: @SterlynKong

Refs: #136
2026-05-09 01:02:00 +02:00

358 lines
13 KiB
JavaScript

/**
* BetterDesk Console - Node ↔ Go user sync service
*
* Background:
* The Node.js panel persists users in its own auth.db (or auth schema in
* PostgreSQL), while the Go server keeps a separate `users` table in
* db_v2.sqlite3 (or PostgreSQL). Org membership on the Go side links via
* `org_users.server_user_id` — i.e. only users present in the Go store can
* be linked to organizations through `Add User → Add Existing`
* (Issue #125).
*
* Historically, the Node panel created users only locally, so the Go
* `users` table only contained the seeded admin. This service mirrors
* user create/update/delete operations to the Go server via its REST API,
* so the org "available users" dropdown stays consistent with the Users
* admin page.
*
* All mirror calls are best-effort: failures are logged but do not break
* the panel-side request — users remain functional locally even when the
* Go server is unreachable. Backfill helpers are run at startup to
* reconcile drift in both directions. SQLite installs can recover local
* auth.db users from the Go db_v2.sqlite3 users table, preserving password
* hashes so panel login keeps working after an update recreated auth.db.
*/
const crypto = require('crypto');
const { apiClient } = require('./betterdeskApi');
const db = require('./database');
// Roles supported by the Go server (auth/roles.go). Anything outside this
// list is downgraded to 'viewer' so the mirror call does not fail.
const GO_VALID_ROLES = new Set([
'super_admin',
'admin',
'server_admin',
'global_admin',
'operator',
'viewer',
'pro',
]);
function normalizeRole(role) {
return GO_VALID_ROLES.has(role) ? role : 'viewer';
}
function normalizeUsername(username) {
return String(username || '').trim().toLowerCase();
}
function sqliteTableExists(sqliteDb, tableName) {
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) return false;
try {
return !!sqliteDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName);
} catch (_) {
return false;
}
}
function sqliteColumns(sqliteDb, tableName) {
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) return new Set();
try {
return new Set(sqliteDb.prepare(`PRAGMA table_info(${tableName})`).all().map(c => c.name));
} catch (_) {
return new Set();
}
}
function readGoUsersFromSqlite() {
if (db.type !== 'sqlite') return [];
if (typeof db.getDb !== 'function') return [];
let goDb;
try {
goDb = db.getDb();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot open Go SQLite DB: ${err.message}`);
return [];
}
if (!sqliteTableExists(goDb, 'users')) return [];
const cols = sqliteColumns(goDb, 'users');
if (!cols.has('username') || !cols.has('password_hash')) return [];
const select = [
cols.has('id') ? 'id' : '0 AS id',
'username',
'password_hash',
cols.has('role') ? "COALESCE(role, 'viewer') AS role" : "'viewer' AS role",
cols.has('totp_secret') ? "COALESCE(totp_secret, '') AS totp_secret" : "'' AS totp_secret",
cols.has('totp_enabled') ? 'COALESCE(totp_enabled, 0) AS totp_enabled' : '0 AS totp_enabled',
cols.has('created_at') ? 'created_at' : "datetime('now') AS created_at",
cols.has('last_login') ? 'last_login' : 'NULL AS last_login',
];
try {
return goDb.prepare(`SELECT ${select.join(', ')} FROM users ORDER BY id`).all();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot read Go users: ${err.message}`);
return [];
}
}
function randomPassword() {
// 32 hex chars — used only as a Go-side placeholder. Panel login keeps
// using the Node bcrypt hash; admin can later reset the password through
// the panel which mirrors the new password to Go.
return crypto.randomBytes(16).toString('hex');
}
async function findGoUserByUsername(username) {
if (!username) return null;
try {
const { data } = await apiClient.get('/users');
if (!Array.isArray(data)) return null;
const lower = normalizeUsername(username);
return data.find(u => normalizeUsername(u.username) === lower) || null;
} catch (err) {
const status = err.response?.status;
console.warn(`[userSync] findGoUser('${username}') failed: status=${status} ${err.message}`);
return null;
}
}
async function resolveGoUserId(localUserId) {
const id = Number(localUserId);
if (!Number.isInteger(id) || id <= 0) return null;
let localUser;
try {
localUser = await db.getUserById(id);
} catch (err) {
console.warn(`[userSync] resolveGoUserId(${localUserId}) local lookup failed: ${err.message}`);
return null;
}
if (!localUser) return null;
const goUser = await findGoUserByUsername(localUser.username);
return goUser?.id || localUser.id;
}
/**
* Mirror a freshly created Node user into the Go users table.
* Safe to call when the user already exists on the Go side (logged + ignored).
*/
async function mirrorCreate(username, password, role) {
if (!username || !password) return;
try {
await apiClient.post('/users', {
username,
password,
role: normalizeRole(role),
});
console.log(`[userSync] Mirrored create -> Go: '${username}' (${normalizeRole(role)})`);
} catch (err) {
const status = err.response?.status;
// 409 = username already exists on Go side → still sync the role/password.
if (status === 409) {
await mirrorUpdate(username, { password, role });
return;
}
console.warn(`[userSync] mirrorCreate('${username}') failed: status=${status} ${err.message}`);
}
}
/**
* Mirror an update (role and/or password) to the Go side.
* Looks up the Go user by username (IDs differ between stores).
*/
async function mirrorUpdate(username, { password, role } = {}) {
if (!username) return;
if (!password && !role) return;
let goUser = await findGoUserByUsername(username);
// If the user does not yet exist on Go and we have a plaintext password,
// create the record so subsequent operations (org linking) work.
if (!goUser && password) {
await mirrorCreate(username, password, role);
return;
}
if (!goUser) {
// No Go record and no plaintext password — nothing we can do safely.
return;
}
const body = {};
if (password) body.password = password;
if (role) body.role = normalizeRole(role);
if (Object.keys(body).length === 0) return;
try {
await apiClient.put(`/users/${goUser.id}`, body);
console.log(`[userSync] Mirrored update -> Go: '${username}'${body.role ? ` role=${body.role}` : ''}${body.password ? ' password=***' : ''}`);
} catch (err) {
const status = err.response?.status;
console.warn(`[userSync] mirrorUpdate('${username}') failed: status=${status} ${err.message}`);
}
}
/**
* Mirror a delete to the Go side. Looks up the user by username first.
*/
async function mirrorDelete(username) {
if (!username) return;
const goUser = await findGoUserByUsername(username);
if (!goUser) return;
try {
await apiClient.delete(`/users/${goUser.id}`);
console.log(`[userSync] Mirrored delete -> Go: '${username}'`);
} catch (err) {
const status = err.response?.status;
// 409 = "Cannot delete the last admin user" — keep the panel record
// anyway so the operator can react. Already logged here.
console.warn(`[userSync] mirrorDelete('${username}') failed: status=${status} ${err.message}`);
}
}
/**
* Backfill: ensure every Node panel user has a matching Go-side user record.
* Called once at startup. Missing users are created on the Go side with a
* random throwaway password (panel login keeps using the Node bcrypt hash —
* the Go password is irrelevant unless the operator later resets it).
*/
async function backfillFromNode() {
let nodeUsers;
try {
nodeUsers = await db.getAllUsers();
} catch (err) {
console.warn(`[userSync] backfill: cannot read Node users: ${err.message}`);
return;
}
if (!Array.isArray(nodeUsers) || nodeUsers.length === 0) return;
let goUsers;
try {
const { data } = await apiClient.get('/users');
goUsers = Array.isArray(data) ? data : [];
} catch (err) {
const status = err.response?.status;
console.warn(`[userSync] backfill: cannot read Go users: status=${status} ${err.message}`);
return;
}
const goUsernames = new Set(goUsers.map(u => String(u.username || '').toLowerCase()));
const missing = nodeUsers.filter(u => !goUsernames.has(String(u.username || '').toLowerCase()));
if (missing.length === 0) {
console.log(`[userSync] backfill: all ${nodeUsers.length} panel users already present on Go side`);
return;
}
console.log(`[userSync] backfill: mirroring ${missing.length} panel user(s) to Go server`);
for (const u of missing) {
try {
await apiClient.post('/users', {
username: u.username,
password: randomPassword(),
role: normalizeRole(u.role),
});
console.log(`[userSync] backfill: created Go user '${u.username}' (${normalizeRole(u.role)})`);
} catch (err) {
const status = err.response?.status;
if (status === 409) continue; // race — already exists, fine.
console.warn(`[userSync] backfill: failed to create '${u.username}': status=${status} ${err.message}`);
}
}
}
/**
* Backfill: recover Node auth.db users from the Go server SQLite database.
*
* This intentionally only runs in SQLite mode. PostgreSQL deployments already
* share the same `users` table between Node and Go, while the Go REST API does
* not expose password hashes. Reading db_v2.sqlite3 directly lets us preserve
* bcrypt/PBKDF2 hashes and keep existing users able to log in after auth.db was
* recreated during an update.
*/
async function backfillFromGo() {
if (db.type !== 'sqlite') return { imported: 0, skipped: 'shared-db' };
if (typeof db.getAuthDb !== 'function') return { imported: 0, skipped: 'no-auth-db' };
const goUsers = readGoUsersFromSqlite()
.filter(u => normalizeUsername(u.username) && String(u.password_hash || '').trim() !== '');
if (goUsers.length === 0) return { imported: 0 };
let localUsers;
try {
localUsers = await db.getAllUsersForBackup();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot read local users: ${err.message}`);
return { imported: 0, error: err.message };
}
const localByUsername = new Set((localUsers || []).map(u => normalizeUsername(u.username)));
const localIds = new Set((localUsers || []).map(u => Number(u.id)).filter(Number.isInteger));
const missing = goUsers.filter(u => !localByUsername.has(normalizeUsername(u.username)));
if (missing.length === 0) return { imported: 0 };
let authDb;
try {
authDb = db.getAuthDb();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot open local auth DB: ${err.message}`);
return { imported: 0, error: err.message };
}
const insertWithId = authDb.prepare(`
INSERT INTO users (id, username, password_hash, role, created_at, last_login, totp_secret, totp_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertWithoutId = authDb.prepare(`
INSERT INTO users (username, password_hash, role, created_at, last_login, totp_secret, totp_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
let imported = 0;
for (const user of missing) {
const username = String(user.username || '').trim();
const passwordHash = String(user.password_hash || '').trim();
const role = normalizeRole(user.role);
const createdAt = user.created_at || new Date().toISOString();
const lastLogin = user.last_login || null;
const totpSecret = user.totp_secret || null;
const totpEnabled = user.totp_enabled ? 1 : 0;
const goId = Number(user.id);
try {
if (Number.isInteger(goId) && goId > 0 && !localIds.has(goId)) {
insertWithId.run(goId, username, passwordHash, role, createdAt, lastLogin, totpSecret, totpEnabled);
localIds.add(goId);
} else {
insertWithoutId.run(username, passwordHash, role, createdAt, lastLogin, totpSecret, totpEnabled);
}
localByUsername.add(normalizeUsername(username));
imported++;
console.log(`[userSync] Go->Node backfill: restored local user '${username}' (${role})`);
} catch (err) {
console.warn(`[userSync] Go->Node backfill: failed to restore '${username}': ${err.message}`);
}
}
if (imported > 0) {
console.log(`[userSync] Go->Node backfill: restored ${imported} user(s) into local auth DB`);
}
return { imported };
}
module.exports = {
findGoUserByUsername,
resolveGoUserId,
mirrorCreate,
mirrorUpdate,
mirrorDelete,
backfillFromGo,
backfillFromNode,
};