Update package version to 1.1.3, add new dependencies for authentication and database management, and implement user authentication features including registration and login. Enhance API routes for protected access and integrate WebSocket authentication.

This commit is contained in:
simos
2025-07-09 18:25:58 +00:00
parent b27702797f
commit ec9ff3336a
21 changed files with 2670 additions and 91 deletions

100
server/database/db.js Normal file
View File

@@ -0,0 +1,100 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const DB_PATH = path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Create database connection
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database');
}
});
// Initialize database with schema
const initializeDatabase = async () => {
return new Promise((resolve, reject) => {
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL, (err) => {
if (err) {
console.error('Error initializing database:', err.message);
reject(err);
} else {
console.log('Database initialized successfully');
resolve();
}
});
} catch (error) {
console.error('Error reading init SQL file:', error);
reject(error);
}
});
};
// User database operations
const userDb = {
// Check if any users exist
hasUsers: () => {
return new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM users', (err, row) => {
if (err) reject(err);
else resolve(row.count > 0);
});
});
},
// Create a new user
createUser: (username, passwordHash) => {
return new Promise((resolve, reject) => {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
stmt.run(username, passwordHash, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, username });
}
});
stmt.finalize();
});
},
// Get user by username
getUserByUsername: (username) => {
return new Promise((resolve, reject) => {
db.get('SELECT * FROM users WHERE username = ? AND is_active = 1', [username], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
},
// Update last login time
updateLastLogin: (userId) => {
return new Promise((resolve, reject) => {
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [userId], (err) => {
if (err) reject(err);
else resolve();
});
});
},
// Get user by ID
getUserById: (userId) => {
return new Promise((resolve, reject) => {
db.get('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1', [userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
};
module.exports = {
db,
initializeDatabase,
userDb
};

16
server/database/init.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Initialize authentication database
PRAGMA foreign_keys = ON;
-- Users table (single user system)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);

View File

@@ -33,6 +33,9 @@ const fetch = require('node-fetch');
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git');
const authRoutes = require('./routes/auth');
const { initializeDatabase } = require('./database/db');
const { validateApiKey, authenticateToken, authenticateWebSocket } = require('./middleware/auth');
// File system watcher for projects folder
let projectsWatcher = null;
@@ -142,19 +145,43 @@ const wss = new WebSocketServer({
server,
verifyClient: (info) => {
console.log('WebSocket connection attempt to:', info.req.url);
return true; // Accept all connections for now
// Extract token from query parameters or headers
const url = new URL(info.req.url, 'http://localhost');
const token = url.searchParams.get('token') ||
info.req.headers.authorization?.split(' ')[1];
// Verify token
const user = authenticateWebSocket(token);
if (!user) {
console.log('❌ WebSocket authentication failed');
return false;
}
// Store user info in the request for later use
info.req.user = user;
console.log('✅ WebSocket authenticated for user:', user.username);
return true;
}
});
app.use(cors());
app.use(express.json());
// Optional API key validation (if configured)
app.use('/api', validateApiKey);
// Authentication routes (public)
app.use('/api/auth', authRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
// Static files served after API routes
app.use(express.static(path.join(__dirname, '../dist')));
// Git API Routes
app.use('/api/git', gitRoutes);
// API Routes
app.get('/api/config', (req, res) => {
// API Routes (protected)
app.get('/api/config', authenticateToken, (req, res) => {
// Always use the server's actual IP and port for WebSocket connections
const serverIP = getServerIP();
const host = `${serverIP}:${PORT}`;
@@ -168,7 +195,7 @@ app.get('/api/config', (req, res) => {
});
});
app.get('/api/projects', async (req, res) => {
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
res.json(projects);
@@ -177,7 +204,7 @@ app.get('/api/projects', async (req, res) => {
}
});
app.get('/api/projects/:projectName/sessions', async (req, res) => {
app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
try {
const { limit = 5, offset = 0 } = req.query;
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
@@ -188,7 +215,7 @@ app.get('/api/projects/:projectName/sessions', async (req, res) => {
});
// Get messages for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const messages = await getSessionMessages(projectName, sessionId);
@@ -199,7 +226,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, r
});
// Rename project endpoint
app.put('/api/projects/:projectName/rename', async (req, res) => {
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
try {
const { displayName } = req.body;
await renameProject(req.params.projectName, displayName);
@@ -210,7 +237,7 @@ app.put('/api/projects/:projectName/rename', async (req, res) => {
});
// Delete session endpoint
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
await deleteSession(projectName, sessionId);
@@ -221,7 +248,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) =>
});
// Delete project endpoint (only if empty)
app.delete('/api/projects/:projectName', async (req, res) => {
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
@@ -232,7 +259,7 @@ app.delete('/api/projects/:projectName', async (req, res) => {
});
// Create project endpoint
app.post('/api/projects/create', async (req, res) => {
app.post('/api/projects/create', authenticateToken, async (req, res) => {
try {
const { path: projectPath } = req.body;
@@ -249,7 +276,7 @@ app.post('/api/projects/create', async (req, res) => {
});
// Read file content endpoint
app.get('/api/projects/:projectName/file', async (req, res) => {
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath } = req.query;
@@ -278,7 +305,7 @@ app.get('/api/projects/:projectName/file', async (req, res) => {
});
// Serve binary file content endpoint (for images, etc.)
app.get('/api/projects/:projectName/files/content', async (req, res) => {
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: filePath } = req.query;
@@ -324,7 +351,7 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
});
// Save file content endpoint
app.put('/api/projects/:projectName/file', async (req, res) => {
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath, content } = req.body;
@@ -371,7 +398,7 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
}
});
app.get('/api/projects/:projectName/files', async (req, res) => {
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const fs = require('fs').promises;
@@ -409,12 +436,16 @@ wss.on('connection', (ws, request) => {
const url = request.url;
console.log('🔗 Client connected to:', url);
if (url === '/shell') {
// Parse URL to get pathname without query parameters
const urlObj = new URL(url, 'http://localhost');
const pathname = urlObj.pathname;
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (url === '/ws') {
} else if (pathname === '/ws') {
handleChatConnection(ws);
} else {
console.log('❌ Unknown WebSocket path:', url);
console.log('❌ Unknown WebSocket path:', pathname);
ws.close();
}
});
@@ -629,7 +660,7 @@ function handleShellConnection(ws) {
});
}
// Audio transcription endpoint
app.post('/api/transcribe', async (req, res) => {
app.post('/api/transcribe', authenticateToken, async (req, res) => {
try {
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@@ -835,9 +866,24 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
});
// Initialize database and start server
async function startServer() {
try {
// Initialize authentication database
await initializeDatabase();
console.log('✅ Database initialized successfully');
server.listen(PORT, '0.0.0.0', () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
});
} catch (error) {
console.error('❌ Failed to start server:', error);
process.exit(1);
}
}
startServer();

80
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,80 @@
const jwt = require('jsonwebtoken');
const { userDb } = require('../database/db');
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
// Optional API key middleware
const validateApiKey = (req, res, next) => {
// Skip API key validation if not configured
if (!process.env.API_KEY) {
return next();
}
const apiKey = req.headers['x-api-key'];
if (apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
};
// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user still exists and is active
const user = await userDb.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
req.user = user;
next();
} catch (error) {
console.error('Token verification error:', error);
return res.status(403).json({ error: 'Invalid token' });
}
};
// Generate JWT token (never expires)
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
},
JWT_SECRET
// No expiration - token lasts forever
);
};
// WebSocket authentication function
const authenticateWebSocket = (token) => {
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
}
};
module.exports = {
validateApiKey,
authenticateToken,
generateToken,
authenticateWebSocket,
JWT_SECRET
};

125
server/routes/auth.js Normal file
View File

@@ -0,0 +1,125 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { userDb } = require('../database/db');
const { generateToken, authenticateToken } = require('../middleware/auth');
const router = express.Router();
// Check auth status and setup requirements
router.get('/status', async (req, res) => {
try {
const hasUsers = await userDb.hasUsers();
res.json({
needsSetup: !hasUsers,
isAuthenticated: false // Will be overridden by frontend if token exists
});
} catch (error) {
console.error('Auth status error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// User registration (setup) - only allowed if no users exist
router.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (username.length < 3 || password.length < 6) {
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
}
// Check if users already exist (only allow one user)
const hasUsers = await userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = await userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
await userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Registration error:', error);
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
res.status(409).json({ error: 'Username already exists' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// User login
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
// Get user from database
const user = await userDb.getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Generate token
const token = generateToken(user);
// Update last login
await userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get current user (protected route)
router.get('/user', authenticateToken, (req, res) => {
res.json({
user: req.user
});
});
// Logout (client-side token removal, but this endpoint can be used for logging)
router.post('/logout', authenticateToken, (req, res) => {
// In a simple JWT system, logout is mainly client-side
// This endpoint exists for consistency and potential future logging
res.json({ success: true, message: 'Logged out successfully' });
});
module.exports = router;