feat(auth): implement user authentication and password change functionality

This commit is contained in:
samanhappy
2025-04-12 21:55:26 +08:00
parent 929780c415
commit 5532c19305
25 changed files with 1405 additions and 43 deletions

View File

@@ -23,7 +23,7 @@ export const loadSettings = (): McpSettings => {
return JSON.parse(settingsData);
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
return { mcpServers: {} };
return { mcpServers: {}, users: [] };
}
};

View File

@@ -0,0 +1,179 @@
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { validationResult } from 'express-validator';
import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
const TOKEN_EXPIRY = '24h';
// Login user
export const login = async (req: Request, res: Response): Promise<void> => {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
return;
}
const { username, password } = req.body;
try {
// Find user by username
const user = findUserByUsername(username);
if (!user) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
return;
}
// Verify password
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
return;
}
// Generate JWT token
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin || false
}
};
jwt.sign(
payload,
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY },
(err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
}
);
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
// Register new user
export const register = async (req: Request, res: Response): Promise<void> => {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
return;
}
const { username, password, isAdmin } = req.body;
try {
// Create new user
const newUser = await createUser({ username, password, isAdmin });
if (!newUser) {
res.status(400).json({ success: false, message: 'User already exists' });
return;
}
// Generate JWT token
const payload = {
user: {
username: newUser.username,
isAdmin: newUser.isAdmin || false
}
};
jwt.sign(
payload,
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY },
(err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: newUser.username,
isAdmin: newUser.isAdmin
}
});
}
);
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
// Get current user
export const getCurrentUser = (req: Request, res: Response): void => {
try {
// User is already attached to request by auth middleware
const user = (req as any).user;
res.json({
success: true,
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
} catch (error) {
console.error('Get current user error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
// Change password
export const changePassword = async (req: Request, res: Response): Promise<void> => {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
return;
}
const { currentPassword, newPassword } = req.body;
const username = (req as any).user.username;
try {
// Find user by username
const user = findUserByUsername(username);
if (!user) {
res.status(404).json({ success: false, message: 'User not found' });
return;
}
// Verify current password
const isPasswordValid = await verifyPassword(currentPassword, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Current password is incorrect' });
return;
}
// Update the password
const updated = await updateUserPassword(username, newPassword);
if (!updated) {
res.status(500).json({ success: false, message: 'Failed to update password' });
return;
}
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};

28
src/middlewares/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
// Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => {
// Get token from header
const token = req.header('x-auth-token');
// Check if no token
if (!token) {
res.status(401).json({ success: false, message: 'No token, authorization denied' });
return;
}
// Verify token
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Add user from payload to request
(req as any).user = (decoded as any).user;
next();
} catch (error) {
res.status(401).json({ success: false, message: 'Token is not valid' });
}
};

View File

@@ -1,5 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { auth } from './auth.js';
import { initializeDefaultUser } from '../models/User.js';
export const errorHandler = (
err: Error,
@@ -25,6 +27,14 @@ export const initMiddlewares = (app: express.Application): void => {
}
});
// Initialize default admin user if no users exist
initializeDefaultUser().catch(err => {
console.error('Error initializing default user:', err);
});
// Protect all API routes with authentication middleware
app.use('/api', auth);
app.get('/', (_req: Request, res: Response) => {
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
});

103
src/models/User.ts Normal file
View File

@@ -0,0 +1,103 @@
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
import { IUser, McpSettings } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
// Get all users
export const getUsers = (): IUser[] => {
try {
const settings = loadSettings();
return settings.users || [];
} catch (error) {
console.error('Error reading users from settings:', error);
return [];
}
};
// Save users to settings
const saveUsers = (users: IUser[]): void => {
try {
const settings = loadSettings();
settings.users = users;
saveSettings(settings);
} catch (error) {
console.error('Error saving users to settings:', error);
}
};
// Create a new user
export const createUser = async (userData: IUser): Promise<IUser | null> => {
const users = getUsers();
// Check if username already exists
if (users.some(user => user.username === userData.username)) {
return null;
}
// Hash the password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
const newUser = {
username: userData.username,
password: hashedPassword,
isAdmin: userData.isAdmin || false
};
users.push(newUser);
saveUsers(users);
return newUser;
};
// Find user by username
export const findUserByUsername = (username: string): IUser | undefined => {
const users = getUsers();
return users.find(user => user.username === username);
};
// Verify user password
export const verifyPassword = async (
plainPassword: string,
hashedPassword: string
): Promise<boolean> => {
return await bcrypt.compare(plainPassword, hashedPassword);
};
// Update user password
export const updateUserPassword = async (
username: string,
newPassword: string
): Promise<boolean> => {
const users = getUsers();
const userIndex = users.findIndex(user => user.username === username);
if (userIndex === -1) {
return false;
}
// Hash the new password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
// Update the user's password
users[userIndex].password = hashedPassword;
saveUsers(users);
return true;
};
// Initialize with default admin user if no users exist
export const initializeDefaultUser = async (): Promise<void> => {
const users = getUsers();
if (users.length === 0) {
await createUser({
username: 'admin',
password: 'admin123',
isAdmin: true
});
console.log('Default admin user created');
}
};

View File

@@ -1,4 +1,5 @@
import express from 'express';
import { check } from 'express-validator';
import {
getAllServers,
getAllSettings,
@@ -6,15 +7,43 @@ import {
updateServer,
deleteServer,
} from '../controllers/serverController.js';
import {
login,
register,
getCurrentUser,
changePassword
} from '../controllers/authController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
export const initRoutes = (app: express.Application): void => {
// API routes protected by auth middleware in middlewares/index.ts
router.get('/servers', getAllServers);
router.get('/settings', getAllSettings);
router.post('/servers', createServer);
router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer);
// Auth routes (these will NOT be protected by auth middleware)
app.post('/auth/login', [
check('username', 'Username is required').not().isEmpty(),
check('password', 'Password is required').not().isEmpty(),
], login);
app.post('/auth/register', [
check('username', 'Username is required').not().isEmpty(),
check('password', 'Password must be at least 6 characters').isLength({ min: 6 }),
], register);
app.get('/auth/user', auth, getCurrentUser);
// Add change password route
app.post('/auth/change-password', [
auth,
check('currentPassword', 'Current password is required').not().isEmpty(),
check('newPassword', 'New password must be at least 6 characters').isLength({ min: 6 }),
], changePassword);
app.use('/api', router);
};

View File

@@ -4,6 +4,8 @@ import { initMcpServer, registerAllTools } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
import { migrateUserData } from './utils/migration.js';
import { initializeDefaultUser } from './models/User.js';
export class AppServer {
private app: express.Application;
@@ -16,6 +18,12 @@ export class AppServer {
async initialize(): Promise<void> {
try {
// Migrate user data from users.json to mcp_settings.json if needed
migrateUserData();
// Initialize default admin user if no users exist
await initializeDefaultUser();
const mcpServer = await initMcpServer(config.mcpHubName, config.mcpHubVersion);
await registerAllTools(mcpServer, true);
initMiddlewares(this.app);

View File

@@ -2,8 +2,16 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
// User interface
export interface IUser {
username: string;
password: string;
isAdmin?: boolean;
}
// Represents the settings for MCP servers
export interface McpSettings {
users?: IUser[]; // Array of user credentials and permissions
mcpServers: {
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
};

52
src/utils/migration.ts Normal file
View File

@@ -0,0 +1,52 @@
// filepath: /Users/sunmeng/code/github/mcphub/src/utils/migration.ts
import fs from 'fs';
import path from 'path';
import { loadSettings, saveSettings } from '../config/index.js';
import { IUser } from '../types/index.js';
/**
* Migrates user data from the old users.json file to mcp_settings.json
* This is a one-time migration to support the refactoring from separate
* users.json to integrated user data in mcp_settings.json
*/
export const migrateUserData = (): void => {
const oldUsersFilePath = path.join(process.cwd(), 'data', 'users.json');
// Check if the old users file exists
if (fs.existsSync(oldUsersFilePath)) {
try {
// Read users from the old file
const usersData = fs.readFileSync(oldUsersFilePath, 'utf8');
const users = JSON.parse(usersData) as IUser[];
if (users && Array.isArray(users) && users.length > 0) {
console.log(`Migrating ${users.length} users from users.json to mcp_settings.json`);
// Load current settings
const settings = loadSettings();
// Merge users, giving priority to existing settings users
const existingUsernames = new Set((settings.users || []).map(u => u.username));
const newUsers = users.filter(u => !existingUsernames.has(u.username));
settings.users = [...(settings.users || []), ...newUsers];
// Save updated settings
if (saveSettings(settings)) {
console.log('User data migration completed successfully');
// Rename the old file as backup
const backupPath = `${oldUsersFilePath}.bak.${Date.now()}`;
fs.renameSync(oldUsersFilePath, backupPath);
console.log(`Renamed old users file to ${backupPath}`);
}
} else {
console.log('No users found in users.json, skipping migration');
}
} catch (error) {
console.error('Error during user data migration:', error);
}
} else {
console.log('users.json not found, no migration needed');
}
};