From 8c3fad5e826beda0004978270ac80bfd2fc4ad03 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:36:39 +0200 Subject: [PATCH] fix(auth): reset-admin.js ends the account's sessions and matches its email in any case Resetting a locked-out or compromised admin left every session on that account valid, because the script did not move password_version on the way every password change in the app does. It also looked the address up case-sensitively and created a second account when the stored one had capitals. --- server/reset-admin.js | 9 ++++++--- server/tests/unit/db/durability.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/server/reset-admin.js b/server/reset-admin.js index cf0b3835a..743837c98 100644 --- a/server/reset-admin.js +++ b/server/reset-admin.js @@ -58,11 +58,14 @@ db.exec(`PRAGMA journal_mode = ${journalMode}`); db.exec(`PRAGMA synchronous = ${synchronous}`); const hash = bcrypt.hashSync(password, BCRYPT_COST); -const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); +// Case-insensitive like login, so Admin@example.com is found as admin@example.com. +const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email); if (existing) { - db.prepare('UPDATE users SET password_hash = ?, role = ?, must_change_password = 1 WHERE email = ?') - .run(hash, 'admin', email); + // password_version moves on, as on every password change in the app: it is + // what ends the sessions still signed in to an account that needed resetting. + db.prepare('UPDATE users SET password_hash = ?, role = ?, must_change_password = 1, password_version = COALESCE(password_version, 0) + 1 WHERE id = ?') + .run(hash, 'admin', existing.id); console.log(`\nāœ“ Admin password reset: ${email}`); } else { // 'admin' is usually taken by the first-run seed — pick the first free username diff --git a/server/tests/unit/db/durability.test.ts b/server/tests/unit/db/durability.test.ts index 23ae39020..4adb06018 100644 --- a/server/tests/unit/db/durability.test.ts +++ b/server/tests/unit/db/durability.test.ts @@ -184,6 +184,7 @@ describe('standalone scripts honour the same configuration', () => { password_hash TEXT, role TEXT, must_change_password INTEGER DEFAULT 0, + password_version INTEGER NOT NULL DEFAULT 0, mfa_secret TEXT, maps_api_key TEXT, openweather_api_key TEXT, @@ -226,6 +227,24 @@ describe('standalone scripts honour the same configuration', () => { expect(admin?.role).toBe('admin'); }, 60000); + it('reset-admin.js finds the account whatever the case, and ends its sessions', () => { + seedWalDb(); + const seed = new Database(dbPath); + seed.prepare("INSERT INTO users (username, email, password_hash, role, password_version) VALUES ('boss', 'Boss@Example.com', 'x', 'user', 3)").run(); + seed.close(); + + execFileSync(process.execPath, ['reset-admin.js'], { + cwd: SERVER_ROOT, + env: { ...process.env, TREK_DB_FILE: dbPath, RESET_ADMIN_EMAIL: 'boss@example.com', RESET_ADMIN_PASSWORD: 'Recovery12345!' }, + stdio: 'pipe', + }); + + const db = new Database(dbPath); + const rows = db.prepare('SELECT email, role, password_version, must_change_password FROM users').all(); + db.close(); + expect(rows).toEqual([{ email: 'Boss@Example.com', role: 'admin', password_version: 4, must_change_password: 1 }]); + }, 60000); + it('migrate-encryption.ts no longer forces the database back to WAL', () => { seedWalDb();