From 5532c19305ef674d36b5ff870e5bead38ff12da9 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Sat, 12 Apr 2025 21:55:26 +0800 Subject: [PATCH] feat(auth): implement user authentication and password change functionality --- frontend/src/App.tsx | 73 +++++- .../src/components/ChangePasswordForm.tsx | 158 ++++++++++++ frontend/src/components/ProtectedRoute.tsx | 27 ++ frontend/src/components/ui/Button.tsx | 0 frontend/src/contexts/AuthContext.tsx | 159 ++++++++++++ frontend/src/locales/en.json | 27 +- frontend/src/locales/zh.json | 27 +- frontend/src/pages/ChangePasswordPage.tsx | 30 +++ frontend/src/pages/LoginPage.tsx | 104 ++++++++ frontend/src/services/authService.ts | 141 +++++++++++ frontend/src/types/index.ts | 36 +++ frontend/src/utils/cn.ts | 0 frontend/vite.config.ts | 4 + mcp_settings.json | 7 + package.json | 6 + pnpm-lock.yaml | 230 +++++++++++++++--- src/config/index.ts | 2 +- src/controllers/authController.ts | 179 ++++++++++++++ src/middlewares/auth.ts | 28 +++ src/middlewares/index.ts | 10 + src/models/User.ts | 103 ++++++++ src/routes/index.ts | 29 +++ src/server.ts | 8 + src/types/index.ts | 8 + src/utils/migration.ts | 52 ++++ 25 files changed, 1405 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/ChangePasswordForm.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/pages/ChangePasswordPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/services/authService.ts create mode 100644 frontend/src/utils/cn.ts create mode 100644 src/controllers/authController.ts create mode 100644 src/middlewares/auth.ts create mode 100644 src/models/User.ts create mode 100644 src/utils/migration.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1486e0e..825e4e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,14 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate } from 'react-router-dom' import { Server, ApiResponse } from './types' import ServerCard from './components/ServerCard' import AddServerForm from './components/AddServerForm' import EditServerForm from './components/EditServerForm' +import LoginPage from './pages/LoginPage' +import ChangePasswordPage from './pages/ChangePasswordPage' +import ProtectedRoute from './components/ProtectedRoute' +import { AuthProvider, useAuth } from './contexts/AuthContext' // 配置选项 const CONFIG = { @@ -18,7 +23,8 @@ const CONFIG = { } } -function App() { +// Dashboard component that contains the main application +const Dashboard = () => { const { t } = useTranslation() const [servers, setServers] = useState([]) const [error, setError] = useState(null) @@ -26,6 +32,8 @@ function App() { const [editingServer, setEditingServer] = useState(null) const [isInitialLoading, setIsInitialLoading] = useState(true) const [fetchAttempts, setFetchAttempts] = useState(0) + const { auth, logout } = useAuth() + const navigate = useNavigate() // 轮询定时器引用 const intervalRef = useRef(null) @@ -47,7 +55,12 @@ function App() { const fetchServers = async () => { try { - const response = await fetch('/api/servers') + const token = localStorage.getItem('mcphub_token'); + const response = await fetch('/api/servers', { + headers: { + 'x-auth-token': token || '' + } + }); const data = await response.json() if (data && data.success && Array.isArray(data.data)) { @@ -95,7 +108,12 @@ function App() { // 初始化加载阶段的请求函数 const fetchInitialData = async () => { try { - const response = await fetch('/api/servers') + const token = localStorage.getItem('mcphub_token'); + const response = await fetch('/api/servers', { + headers: { + 'x-auth-token': token || '' + } + }); const data = await response.json() // 处理API响应中的包装对象,提取data字段 @@ -194,7 +212,12 @@ function App() { const handleServerEdit = (server: Server) => { // Fetch settings to get the full server config before editing - fetch(`/api/settings`) + const token = localStorage.getItem('mcphub_token'); + fetch(`/api/settings`, { + headers: { + 'x-auth-token': token || '' + } + }) .then(response => response.json()) .then((settingsData: ApiResponse<{ mcpServers: Record }>) => { if ( @@ -232,8 +255,12 @@ function App() { const handleServerRemove = async (serverName: string) => { try { + const token = localStorage.getItem('mcphub_token'); const response = await fetch(`/api/servers/${serverName}`, { method: 'DELETE', + headers: { + 'x-auth-token': token || '' + } }) const result = await response.json() @@ -248,6 +275,11 @@ function App() { } } + const handleLogout = () => { + logout() + navigate('/login') + } + return (
@@ -273,7 +305,21 @@ function App() {

{t('app.title')}

- +
+ + + +
{servers.length === 0 ? (
@@ -303,4 +349,21 @@ function App() { ) } +function App() { + return ( + + + + } /> + }> + } /> + } /> + + } /> + + + + ) +} + export default App \ No newline at end of file diff --git a/frontend/src/components/ChangePasswordForm.tsx b/frontend/src/components/ChangePasswordForm.tsx new file mode 100644 index 0000000..59ef950 --- /dev/null +++ b/frontend/src/components/ChangePasswordForm.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChangePasswordCredentials } from '../types'; +import { changePassword } from '../services/authService'; + +interface ChangePasswordFormProps { + onSuccess?: () => void; + onCancel?: () => void; +} + +const ChangePasswordForm: React.FC = ({ onSuccess, onCancel }) => { + const { t } = useTranslation(); + const [formData, setFormData] = useState({ + currentPassword: '', + newPassword: '', + }); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (name === 'confirmPassword') { + setConfirmPassword(value); + } else { + setFormData(prev => ({ ...prev, [name]: value })); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Validate passwords match + if (formData.newPassword !== confirmPassword) { + setError(t('auth.passwordsNotMatch')); + return; + } + + setIsLoading(true); + try { + const response = await changePassword(formData); + + if (response.success) { + setSuccess(true); + if (onSuccess) { + onSuccess(); + } + } else { + setError(response.message || t('auth.changePasswordError')); + } + } catch (err) { + setError(t('auth.changePasswordError')); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

{t('auth.changePassword')}

+ + {success ? ( +
+ {t('auth.changePasswordSuccess')} +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {onCancel && ( + + )} + +
+
+ )} +
+ ); +}; + +export default ChangePasswordForm; \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..00cdd6b --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../contexts/AuthContext'; + +interface ProtectedRouteProps { + redirectPath?: string; +} + +const ProtectedRoute: React.FC = ({ + redirectPath = '/login' +}) => { + const { t } = useTranslation(); + const { auth } = useAuth(); + + if (auth.loading) { + return
{t('app.loading')}
; + } + + if (!auth.isAuthenticated) { + return ; + } + + return ; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..b18dab9 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,159 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { AuthState, IUser } from '../types'; +import * as authService from '../services/authService'; + +// Initial auth state +const initialState: AuthState = { + token: null, + isAuthenticated: false, + loading: true, + user: null, + error: null, +}; + +// Create auth context +const AuthContext = createContext<{ + auth: AuthState; + login: (username: string, password: string) => Promise; + register: (username: string, password: string, isAdmin?: boolean) => Promise; + logout: () => void; +}>({ + auth: initialState, + login: async () => false, + register: async () => false, + logout: () => {}, +}); + +// Auth provider component +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [auth, setAuth] = useState(initialState); + + // Load user if token exists + useEffect(() => { + const loadUser = async () => { + const token = authService.getToken(); + + if (!token) { + setAuth({ + ...initialState, + loading: false, + }); + return; + } + + try { + const response = await authService.getCurrentUser(); + + if (response.success && response.user) { + setAuth({ + token, + isAuthenticated: true, + loading: false, + user: response.user, + error: null, + }); + } else { + authService.removeToken(); + setAuth({ + ...initialState, + loading: false, + }); + } + } catch (error) { + authService.removeToken(); + setAuth({ + ...initialState, + loading: false, + }); + } + }; + + loadUser(); + }, []); + + // Login function + const login = async (username: string, password: string): Promise => { + try { + const response = await authService.login({ username, password }); + + if (response.success && response.token && response.user) { + setAuth({ + token: response.token, + isAuthenticated: true, + loading: false, + user: response.user, + error: null, + }); + return true; + } else { + setAuth({ + ...initialState, + loading: false, + error: response.message || 'Authentication failed', + }); + return false; + } + } catch (error) { + setAuth({ + ...initialState, + loading: false, + error: 'Authentication failed', + }); + return false; + } + }; + + // Register function + const register = async ( + username: string, + password: string, + isAdmin = false + ): Promise => { + try { + const response = await authService.register({ username, password, isAdmin }); + + if (response.success && response.token && response.user) { + setAuth({ + token: response.token, + isAuthenticated: true, + loading: false, + user: response.user, + error: null, + }); + return true; + } else { + setAuth({ + ...initialState, + loading: false, + error: response.message || 'Registration failed', + }); + return false; + } + } catch (error) { + setAuth({ + ...initialState, + loading: false, + error: 'Registration failed', + }); + return false; + } + }; + + // Logout function + const logout = (): void => { + authService.logout(); + setAuth({ + ...initialState, + loading: false, + }); + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use auth context +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d798bbf..9469208 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -3,7 +3,28 @@ "title": "MCP Hub Dashboard", "error": "Error", "closeButton": "Close", - "noServers": "No MCP servers available" + "noServers": "No MCP servers available", + "loading": "Loading...", + "logout": "Logout", + "profile": "Profile", + "changePassword": "Change Password" + }, + "auth": { + "login": "Login", + "loginTitle": "Login to MCP Hub", + "username": "Username", + "password": "Password", + "loggingIn": "Logging in...", + "emptyFields": "Username and password cannot be empty", + "loginFailed": "Login failed, please check your username and password", + "loginError": "An error occurred during login", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmPassword": "Confirm Password", + "passwordsNotMatch": "New password and confirmation do not match", + "changePasswordSuccess": "Password changed successfully", + "changePasswordError": "Failed to change password", + "changePassword": "Change Password" }, "server": { "addServer": "Add Server", @@ -45,5 +66,9 @@ "serverUpdate": "Failed to edit server {{serverName}}. Please check the server status", "serverFetch": "Failed to retrieve server data. Please try again later", "initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..." + }, + "common": { + "save": "Save", + "cancel": "Cancel" } } \ No newline at end of file diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 84437a7..e858d22 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -3,7 +3,28 @@ "title": "MCP Hub 控制面板", "error": "错误", "closeButton": "关闭", - "noServers": "没有可用的 MCP 服务器" + "noServers": "没有可用的 MCP 服务器", + "loading": "加载中...", + "logout": "退出登录", + "profile": "个人资料", + "changePassword": "修改密码" + }, + "auth": { + "login": "登录", + "loginTitle": "登录 MCP Hub", + "username": "用户名", + "password": "密码", + "loggingIn": "登录中...", + "emptyFields": "用户名和密码不能为空", + "loginFailed": "登录失败,请检查用户名和密码", + "loginError": "登录过程中出现错误", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmPassword": "确认密码", + "passwordsNotMatch": "新密码与确认密码不一致", + "changePasswordSuccess": "密码修改成功", + "changePasswordError": "修改密码失败", + "changePassword": "修改密码" }, "server": { "addServer": "添加服务器", @@ -45,5 +66,9 @@ "serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态", "serverFetch": "获取服务器数据失败,请稍后重试", "initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..." + }, + "common": { + "save": "保存", + "cancel": "取消" } } \ No newline at end of file diff --git a/frontend/src/pages/ChangePasswordPage.tsx b/frontend/src/pages/ChangePasswordPage.tsx new file mode 100644 index 0000000..e9c8716 --- /dev/null +++ b/frontend/src/pages/ChangePasswordPage.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import ChangePasswordForm from '../components/ChangePasswordForm'; + +const ChangePasswordPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleSuccess = () => { + setTimeout(() => { + navigate('/'); + }, 2000); + }; + + const handleCancel = () => { + navigate('/'); + }; + + return ( +
+
+

{t('auth.changePassword')}

+ +
+
+ ); +}; + +export default ChangePasswordPage; \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..3420b0c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../contexts/AuthContext'; + +const LoginPage: React.FC = () => { + const { t } = useTranslation(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + if (!username || !password) { + setError(t('auth.emptyFields')); + setLoading(false); + return; + } + + const success = await login(username, password); + + if (success) { + navigate('/'); + } else { + setError(t('auth.loginFailed')); + } + } catch (err) { + setError(t('auth.loginError')); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ {t('auth.loginTitle')} +

+
+
+
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + {error && ( +
{error}
+ )} + +
+ +
+
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts new file mode 100644 index 0000000..0e1dd62 --- /dev/null +++ b/frontend/src/services/authService.ts @@ -0,0 +1,141 @@ +import { AuthResponse, LoginCredentials, RegisterCredentials, ChangePasswordCredentials } from '../types'; + +// Base URL for API requests +const API_URL = ''; + +// Token key in localStorage +const TOKEN_KEY = 'mcphub_token'; + +// Get token from localStorage +export const getToken = (): string | null => { + return localStorage.getItem(TOKEN_KEY); +}; + +// Set token in localStorage +export const setToken = (token: string): void => { + localStorage.setItem(TOKEN_KEY, token); +}; + +// Remove token from localStorage +export const removeToken = (): void => { + localStorage.removeItem(TOKEN_KEY); +}; + +// Login user +export const login = async (credentials: LoginCredentials): Promise => { + try { + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + }); + + const data: AuthResponse = await response.json(); + + if (data.success && data.token) { + setToken(data.token); + } + + return data; + } catch (error) { + console.error('Login error:', error); + return { + success: false, + message: 'An error occurred during login', + }; + } +}; + +// Register user +export const register = async (credentials: RegisterCredentials): Promise => { + try { + const response = await fetch(`${API_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + }); + + const data: AuthResponse = await response.json(); + + if (data.success && data.token) { + setToken(data.token); + } + + return data; + } catch (error) { + console.error('Register error:', error); + return { + success: false, + message: 'An error occurred during registration', + }; + } +}; + +// Get current user +export const getCurrentUser = async (): Promise => { + const token = getToken(); + + if (!token) { + return { + success: false, + message: 'No authentication token', + }; + } + + try { + const response = await fetch(`${API_URL}/auth/user`, { + method: 'GET', + headers: { + 'x-auth-token': token, + }, + }); + + return await response.json(); + } catch (error) { + console.error('Get current user error:', error); + return { + success: false, + message: 'An error occurred while fetching user data', + }; + } +}; + +// Change password +export const changePassword = async (credentials: ChangePasswordCredentials): Promise => { + const token = getToken(); + + if (!token) { + return { + success: false, + message: 'No authentication token', + }; + } + + try { + const response = await fetch(`${API_URL}/auth/change-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-auth-token': token, + }, + body: JSON.stringify(credentials), + }); + + return await response.json(); + } catch (error) { + console.error('Change password error:', error); + return { + success: false, + message: 'An error occurred while changing password', + }; + } +}; + +// Logout user +export const logout = (): void => { + removeToken(); +}; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 014b944..ecf0193 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -52,4 +52,40 @@ export interface ApiResponse { success: boolean; data: T; message?: string; +} + +// Auth types +export interface IUser { + username: string; + isAdmin?: boolean; +} + +export interface AuthState { + token: string | null; + isAuthenticated: boolean; + loading: boolean; + user: IUser | null; + error: string | null; +} + +export interface LoginCredentials { + username: string; + password: string; +} + +export interface RegisterCredentials extends LoginCredentials { + isAdmin?: boolean; +} + +export interface ChangePasswordCredentials { + currentPassword: string; + newPassword: string; +} + +export interface AuthResponse { + success: boolean; + token?: string; + user?: IUser; + message?: string; + errors?: Array<{ msg: string }>; } \ No newline at end of file diff --git a/frontend/src/utils/cn.ts b/frontend/src/utils/cn.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 853e72c..086adae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -17,6 +17,10 @@ export default defineConfig({ target: 'http://localhost:3000', changeOrigin: true, }, + '/auth': { + target: 'http://localhost:3000', + changeOrigin: true, + }, }, }, }); diff --git a/mcp_settings.json b/mcp_settings.json index 4573dd7..5bb9ad2 100644 --- a/mcp_settings.json +++ b/mcp_settings.json @@ -1,4 +1,11 @@ { + "users": [ + { + "username": "admin", + "password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.", + "isAdmin": true + } + ], "mcpServers": { "amap-maps": { "command": "npx", diff --git a/package.json b/package.json index 116596d..2dd102a 100644 --- a/package.json +++ b/package.json @@ -31,18 +31,22 @@ "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", "autoprefixer": "^10.4.21", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-validator": "^7.2.1", "i18next": "^24.2.3", "i18next-browser-languagedetector": "^8.0.4", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.486.0", "next": "^15.2.4", "postcss": "^8.5.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.4.1", + "react-router-dom": "^7.5.0", "tailwind-merge": "^3.1.0", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss": "^4.0.17", @@ -50,8 +54,10 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.3", + "@types/bcryptjs": "^3.0.0", "@types/express": "^4.17.21", "@types/jest": "^29.5.5", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.8.2", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6602a9c..b58c06e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.3) + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -44,12 +47,18 @@ importers: express: specifier: ^4.18.2 version: 4.21.2 + express-validator: + specifier: ^7.2.1 + version: 7.2.1 i18next: specifier: ^24.2.3 version: 24.2.3(typescript@5.8.2) i18next-browser-languagedetector: specifier: ^8.0.4 version: 8.0.4 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 lucide-react: specifier: ^0.486.0 version: 0.486.0(react@19.1.0) @@ -68,6 +77,9 @@ importers: react-i18next: specifier: ^15.4.1 version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-router-dom: + specifier: ^7.5.0 + version: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.1.0 version: 3.1.0 @@ -84,12 +96,18 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.3 version: 4.1.3 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/express': specifier: ^4.17.21 version: 4.17.21 '@types/jest': specifier: ^29.5.5 version: 29.5.14 + '@types/jsonwebtoken': + specifier: ^9.0.9 + version: 9.0.9 '@types/node': specifier: ^20.8.2 version: 20.17.28 @@ -669,79 +687,67 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -879,28 +885,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.2.4': resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.2.4': resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.2.4': resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.2.4': resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==} @@ -1100,67 +1102,56 @@ packages: resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.39.0': resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.39.0': resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.39.0': resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.39.0': resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.39.0': resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.39.0': resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.39.0': resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.39.0': resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.39.0': resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.39.0': resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.39.0': resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==} @@ -1234,28 +1225,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.3': resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.3': resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.3': resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-win32-arm64-msvc@4.1.3': resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==} @@ -1305,12 +1292,19 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -1341,9 +1335,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.9': + resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.17.28': resolution: {integrity: sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==} @@ -1558,6 +1558,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcryptjs@3.0.2: + resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1595,6 +1599,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1740,6 +1747,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -1854,6 +1865,9 @@ packages: dynamic-dedupe@0.3.0: resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1997,6 +2011,10 @@ packages: peerDependencies: express: ^4.11 || 5 || ^5.0.0-beta.1 + express-validator@7.2.1: + resolution: {integrity: sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==} + engines: {node: '>= 8.0.0'} + express@4.21.2: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} @@ -2495,6 +2513,16 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2539,28 +2567,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} @@ -2589,12 +2613,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2977,6 +3022,23 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-router-dom@7.5.0: + resolution: {integrity: sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.5.0: + resolution: {integrity: sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -3087,6 +3149,9 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3345,6 +3410,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turbo-stream@2.4.0: + resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3412,6 +3480,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4538,6 +4610,10 @@ snapshots: dependencies: '@babel/types': 7.27.0 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.2 + '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 @@ -4547,6 +4623,8 @@ snapshots: dependencies: '@types/node': 20.17.28 + '@types/cookie@0.6.0': {} + '@types/estree@1.0.7': {} '@types/express-serve-static-core@4.19.6': @@ -4586,8 +4664,15 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.9': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.17.28 + '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node@20.17.28': dependencies: undici-types: 6.19.8 @@ -4857,6 +4942,8 @@ snapshots: base64-js@1.5.1: {} + bcryptjs@3.0.2: {} + binary-extensions@2.3.0: {} bl@5.1.0: @@ -4924,6 +5011,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@6.0.3: @@ -5058,6 +5147,8 @@ snapshots: cookie@0.7.1: {} + cookie@1.0.2: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -5148,6 +5239,10 @@ snapshots: dependencies: xtend: 4.0.2 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} ejs@3.1.10: @@ -5359,6 +5454,11 @@ snapshots: dependencies: express: 5.0.1 + express-validator@7.2.1: + dependencies: + lodash: 4.17.21 + validator: 13.12.0 + express@4.21.2: dependencies: accepts: 1.3.8 @@ -6101,6 +6201,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.1 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6169,10 +6293,24 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} log-symbols@5.1.0: @@ -6498,6 +6636,22 @@ snapshots: react-refresh@0.14.2: {} + react-router-dom@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-router: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + + react-router@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@types/cookie': 0.6.0 + cookie: 1.0.2 + react: 19.1.0 + set-cookie-parser: 2.7.1 + turbo-stream: 2.4.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react@19.1.0: {} readable-stream@3.6.2: @@ -6653,6 +6807,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.1: {} + setprototypeof@1.2.0: {} sharp@0.33.5: @@ -6917,6 +7073,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turbo-stream@2.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -6970,6 +7128,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.12.0: {} + vary@1.1.2: {} vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2): diff --git a/src/config/index.ts b/src/config/index.ts index cc9f9e0..ca77924 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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: [] }; } }; diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts new file mode 100644 index 0000000..fc86bf4 --- /dev/null +++ b/src/controllers/authController.ts @@ -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 => { + // 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 => { + // 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 => { + // 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' }); + } +}; \ No newline at end of file diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts new file mode 100644 index 0000000..d7b9a15 --- /dev/null +++ b/src/middlewares/auth.ts @@ -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' }); + } +}; \ No newline at end of file diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index 39e9378..4aba7e3 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -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')); }); diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..6f1e2d9 --- /dev/null +++ b/src/models/User.ts @@ -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 => { + 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 => { + return await bcrypt.compare(plainPassword, hashedPassword); +}; + +// Update user password +export const updateUserPassword = async ( + username: string, + newPassword: string +): Promise => { + 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 => { + const users = getUsers(); + + if (users.length === 0) { + await createUser({ + username: 'admin', + password: 'admin123', + isAdmin: true + }); + console.log('Default admin user created'); + } +}; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index c48fbc9..2eca564 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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); }; diff --git a/src/server.ts b/src/server.ts index 3d71db8..22eae56 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { 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); diff --git a/src/types/index.ts b/src/types/index.ts index bada9b8..82cade1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 }; diff --git a/src/utils/migration.ts b/src/utils/migration.ts new file mode 100644 index 0000000..493a64a --- /dev/null +++ b/src/utils/migration.ts @@ -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'); + } +}; \ No newline at end of file