Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions server/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
25 changes: 22 additions & 3 deletions server/database/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,13 +134,15 @@ 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,
value TEXT NOT NULL,
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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 = ?');
Expand Down
3 changes: 2 additions & 1 deletion server/database/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions server/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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' }
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions server/utils/validation.js
Original file line number Diff line number Diff line change
@@ -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 };
}
127 changes: 127 additions & 0 deletions tests/reset-password.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});