diff --git a/server/cli.js b/server/cli.js index 36ac4f0a36..80bb7446fe 100755 --- a/server/cli.js +++ b/server/cli.js @@ -17,6 +17,7 @@ import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { validatePassword } from './utils/validation.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -152,6 +153,7 @@ Commands: start Start the Claude Code UI server (default) status Show configuration and data locations update Update to the latest version + reset-password Reset the admin password (interactive) help Show this help information version Show version information @@ -244,6 +246,98 @@ async function updatePackage() { } } +// Reset password via interactive CLI prompt +async function resetPassword() { + const { default: Database } = await import('better-sqlite3'); + const { default: bcrypt } = await import('bcrypt'); + const readline = await import('readline'); + + // Helper: prompt with hidden input (characters not echoed to terminal) + function promptPassword(question) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl._writeToOutput = function (str) { + if (str === question) rl.output.write(str); // show question, swallow typed chars + }; + rl.question(question, (answer) => { + rl.output.write('\n'); + rl.close(); + resolve(answer); + }); + }); + } + + // Open DB directly — avoids side effects of importing server/database/db.js + const dbPath = getDatabasePath(); + let db; + try { + db = new Database(dbPath); + } catch (err) { + console.error(`${c.error('[ERROR]')} Could not open database at: ${dbPath}`); + console.error(` ${err.message}`); + process.exit(1); + } + + // Ensure token_version column exists (CLI may run before server has run migrations) + try { + const columns = db.prepare('PRAGMA table_info(users)').all().map(col => col.name); + if (!columns.includes('token_version')) { + db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0'); + } + } catch (err) { + // Non-fatal: migration may fail if column already added concurrently + } + + // Get the single user + let user; + try { + user = db.prepare('SELECT id, username FROM users WHERE is_active = 1 LIMIT 1').get(); + } catch { + // Table doesn't exist yet — fresh install before server has run + } + if (!user) { + db.close(); + console.error(`${c.error('[ERROR]')} No user account found. Start the server and complete setup first.`); + process.exit(1); + } + + console.log(`\n${c.info('[INFO]')} Resetting password for user: ${c.bright(user.username)}\n`); + + // Prompt for new password (hidden) + const newPassword = await promptPassword('New password: '); + const confirmation = await promptPassword('Confirm password: '); + + const validation = validatePassword(newPassword, confirmation); + if (!validation.ok) { + db.close(); + console.error(`${c.error('[ERROR]')} ${validation.error}`); + process.exit(1); + } + + // Hash first (async), then write to DB (sync) + const passwordHash = await bcrypt.hash(newPassword, 12); + + try { + const result = db.prepare( + 'UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?' + ).run(passwordHash, user.id); + + if (result.changes === 0) { + db.close(); + console.error(`${c.error('[ERROR]')} Password update failed — no rows affected.`); + process.exit(1); + } + + db.close(); + console.log(`\n${c.ok('[OK]')} Password updated successfully.`); + console.log(`${c.info('[INFO]')} All existing sessions have been invalidated. Please log in again.\n`); + } catch (err) { + db.close(); + console.error(`${c.error('[ERROR]')} Failed to update password: ${err.message}`); + process.exit(1); + } +} + // Start the server async function startServer() { // Check for updates silently on startup @@ -316,6 +410,9 @@ async function main() { case 'update': await updatePackage(); break; + case 'reset-password': + await resetPassword(); + break; default: console.error(`\n❌ Unknown command: ${command}`); console.log(' Run "cloudcli help" for usage information.\n'); diff --git a/server/database/db.js b/server/database/db.js index 9ab0ad78ca..0e9ec69725 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -100,6 +100,11 @@ const runMigrations = () => { db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } + if (!columnNames.includes('token_version')) { + console.log('Running migration: Adding token_version column'); + db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0'); + } + db.exec(` CREATE TABLE IF NOT EXISTS user_notification_preferences ( user_id INTEGER PRIMARY KEY, @@ -129,6 +134,7 @@ const runMigrations = () => { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); + // Create app_config table if it doesn't exist (for existing installations) db.exec(`CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, @@ -136,6 +142,7 @@ const runMigrations = () => { created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); + // Create session_names table if it doesn't exist (for existing installations) db.exec(`CREATE TABLE IF NOT EXISTS session_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -185,7 +192,7 @@ const userDb = { try { const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); const result = stmt.run(username, passwordHash); - return { id: result.lastInsertRowid, username }; + return { id: result.lastInsertRowid, username, token_version: 0 }; } catch (err) { throw err; } @@ -213,7 +220,7 @@ const userDb = { // Get user by ID getUserById: (userId) => { try { - const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId); + const row = db.prepare('SELECT id, username, created_at, last_login, token_version FROM users WHERE id = ? AND is_active = 1').get(userId); return row; } catch (err) { throw err; @@ -222,13 +229,25 @@ const userDb = { getFirstUser: () => { try { - const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get(); + const row = db.prepare('SELECT id, username, created_at, last_login, token_version FROM users WHERE is_active = 1 LIMIT 1').get(); return row; } catch (err) { throw err; } }, + updatePassword: (userId, passwordHash) => { + try { + const stmt = db.prepare( + 'UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?' + ); + const result = stmt.run(passwordHash, userId); + return result.changes > 0; + } catch (err) { + throw err; + } + }, + updateGitConfig: (userId, gitName, gitEmail) => { try { const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?'); diff --git a/server/database/init.sql b/server/database/init.sql index 98351516a9..257ad5b1aa 100644 --- a/server/database/init.sql +++ b/server/database/init.sql @@ -11,7 +11,8 @@ CREATE TABLE IF NOT EXISTS users ( is_active BOOLEAN DEFAULT 1, git_name TEXT, git_email TEXT, - has_completed_onboarding BOOLEAN DEFAULT 0 + has_completed_onboarding BOOLEAN DEFAULT 0, + token_version INTEGER DEFAULT 0 ); -- Indexes for performance diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 73749792b6..f2836f65e4 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -58,6 +58,11 @@ const authenticateToken = async (req, res, next) => { return res.status(401).json({ error: 'Invalid token. User not found.' }); } + // Check token version matches current DB version (invalidates tokens after password reset) + if (decoded.tokenVersion !== user.token_version) { + return res.status(401).json({ error: 'Session expired. Please log in again.' }); + } + // Auto-refresh: if token is past halfway through its lifetime, issue a new one if (decoded.exp && decoded.iat) { const now = Math.floor(Date.now() / 1000); @@ -81,7 +86,8 @@ const generateToken = (user) => { return jwt.sign( { userId: user.id, - username: user.username + username: user.username, + tokenVersion: user.token_version }, JWT_SECRET, { expiresIn: '7d' } @@ -111,11 +117,14 @@ const authenticateWebSocket = (token) => { try { const decoded = jwt.verify(token, JWT_SECRET); - // Verify user actually exists in database (matches REST authenticateToken behavior) + // Verify user actually exists and token version is current const user = userDb.getUserById(decoded.userId); if (!user) { return null; } + if (decoded.tokenVersion !== user.token_version) { + return null; + } return { userId: user.id, username: user.username }; } catch (error) { console.error('WebSocket token verification error:', error); diff --git a/server/utils/validation.js b/server/utils/validation.js new file mode 100644 index 0000000000..0c15789d57 --- /dev/null +++ b/server/utils/validation.js @@ -0,0 +1,13 @@ +/** + * Validate a password + confirmation pair. + * Returns { ok: true } or { ok: false, error: string }. + */ +export function validatePassword(password, confirmation) { + if (password.length < 6) { + return { ok: false, error: 'Password must be at least 6 characters.' }; + } + if (password !== confirmation) { + return { ok: false, error: 'Passwords do not match.' }; + } + return { ok: true }; +} diff --git a/tests/reset-password.test.js b/tests/reset-password.test.js new file mode 100644 index 0000000000..30ddce9c02 --- /dev/null +++ b/tests/reset-password.test.js @@ -0,0 +1,127 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import bcrypt from 'bcrypt'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { validatePassword } from '../server/utils/validation.js'; + +// Real modules — dynamically imported after setting DATABASE_PATH +let userDb, initializeDatabase, db; +let generateToken, authenticateToken; +let tmpDbPath; + +before(async () => { + tmpDbPath = path.join(os.tmpdir(), `test-auth-${Date.now()}-${process.pid}.db`); + process.env.DATABASE_PATH = tmpDbPath; + + const dbMod = await import('../server/database/db.js'); + ({ userDb, initializeDatabase, db } = dbMod); + await initializeDatabase(); + + const authMod = await import('../server/middleware/auth.js'); + ({ generateToken, authenticateToken } = authMod); +}); + +after(() => { + db.close(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(tmpDbPath + suffix); } catch {} + } + delete process.env.DATABASE_PATH; +}); + +// Password validation (pure function from server/utils/validation.js) +describe('password validation', () => { + test('rejects passwords shorter than 6 characters', () => { + const result = validatePassword('abc', 'abc'); + assert.equal(result.ok, false); + assert.match(result.error, /6 characters/); + }); + + test('rejects mismatched confirmation', () => { + const result = validatePassword('validpass', 'different'); + assert.equal(result.ok, false); + assert.match(result.error, /do not match/); + }); + + test('accepts valid matching password', () => { + const result = validatePassword('validpass', 'validpass'); + assert.equal(result.ok, true); + }); +}); + +// Database password update (real userDb methods) +describe('database password update', () => { + test('updatePassword changes hash and increments token_version', async () => { + const hash = await bcrypt.hash('oldpassword', 4); + const user = userDb.createUser('update-test-user', hash); + assert.equal(user.token_version, 0); + + const newHash = await bcrypt.hash('newpassword123', 4); + const success = userDb.updatePassword(user.id, newHash); + assert.equal(success, true); + + const updated = userDb.getUserById(user.id); + assert.equal(updated.token_version, 1); + + const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id); + const matches = await bcrypt.compare('newpassword123', row.password_hash); + assert.equal(matches, true); + }); + + test('updatePassword returns false for nonexistent user', () => { + const success = userDb.updatePassword(99999, 'somehash'); + assert.equal(success, false); + }); +}); + +// JWT token invalidation (real generateToken + authenticateToken middleware) +describe('JWT token invalidation', () => { + test('token with old tokenVersion is rejected after password update', async () => { + const hash = await bcrypt.hash('password1', 4); + const user = userDb.createUser('jwt-stale-user', hash); + + const token = generateToken(user); + + // Simulate password reset — increments token_version + const newHash = await bcrypt.hash('password2', 4); + userDb.updatePassword(user.id, newHash); + + const req = { headers: { authorization: `Bearer ${token}` }, query: {} }; + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(data) { this.body = data; return this; }, + setHeader() {}, + }; + let nextCalled = false; + await authenticateToken(req, res, () => { nextCalled = true; }); + + assert.equal(nextCalled, false); + assert.equal(res.statusCode, 401); + assert.match(res.body.error, /expired|log in again/i); + }); + + test('token with current tokenVersion is accepted', async () => { + const hash = await bcrypt.hash('password1', 4); + const user = userDb.createUser('jwt-valid-user', hash); + + const token = generateToken(user); + + const req = { headers: { authorization: `Bearer ${token}` }, query: {} }; + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(data) { this.body = data; return this; }, + setHeader() {}, + }; + let nextCalled = false; + await authenticateToken(req, res, () => { nextCalled = true; }); + + assert.equal(nextCalled, true); + assert.equal(req.user.id, user.id); + }); +});