From d119be0f8258b718d14e7a1a389336a7538bfd39 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Thu, 19 Jun 2025 12:11:35 +0800 Subject: [PATCH] feat: Implement bearer token validation in auth middleware (#186) --- frontend/src/components/ProtectedRoute.tsx | 10 +- frontend/src/contexts/AuthContext.tsx | 43 ++++++--- frontend/src/hooks/useSettingsData.ts | 3 + frontend/src/locales/en.json | 2 + frontend/src/locales/zh.json | 2 + frontend/src/pages/SettingsPage.tsx | 14 ++- frontend/src/services/configService.ts | 102 +++++++++++++++++++++ frontend/src/services/logService.ts | 18 +--- frontend/src/services/toolService.ts | 20 +--- frontend/vite.config.ts | 8 ++ src/controllers/configController.ts | 29 ++++++ src/controllers/serverController.ts | 9 +- src/middlewares/auth.ts | 38 +++++++- src/routes/index.ts | 5 +- src/types/index.ts | 1 + 15 files changed, 248 insertions(+), 56 deletions(-) create mode 100644 frontend/src/services/configService.ts diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 00cdd6b..34a6797 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -7,20 +7,20 @@ interface ProtectedRouteProps { redirectPath?: string; } -const ProtectedRoute: React.FC = ({ - redirectPath = '/login' +const ProtectedRoute: React.FC = ({ + redirectPath = '/login' }) => { const { t } = useTranslation(); const { auth } = useAuth(); - + if (auth.loading) { return
{t('app.loading')}
; } - + if (!auth.isAuthenticated) { return ; } - + return ; }; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index b18dab9..56da07c 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,10 +1,10 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { AuthState, IUser } from '../types'; +import { AuthState } from '../types'; import * as authService from '../services/authService'; +import { shouldSkipAuth } from '../services/configService'; // Initial auth state const initialState: AuthState = { - token: null, isAuthenticated: false, loading: true, user: null, @@ -21,7 +21,7 @@ const AuthContext = createContext<{ auth: initialState, login: async () => false, register: async () => false, - logout: () => {}, + logout: () => { }, }); // Auth provider component @@ -31,8 +31,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // Load user if token exists useEffect(() => { const loadUser = async () => { + // First check if authentication should be skipped + const skipAuth = await shouldSkipAuth(); + + if (skipAuth) { + // If authentication is disabled, set user as authenticated with a dummy user + setAuth({ + isAuthenticated: true, + loading: false, + user: { + username: 'guest', + isAdmin: true, + }, + error: null, + }); + return; + } + + // Normal authentication flow const token = authService.getToken(); - + if (!token) { setAuth({ ...initialState, @@ -40,13 +58,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }); return; } - + try { const response = await authService.getCurrentUser(); - + if (response.success && response.user) { setAuth({ - token, isAuthenticated: true, loading: false, user: response.user, @@ -67,7 +84,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }); } }; - + loadUser(); }, []); @@ -75,10 +92,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => 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, @@ -105,16 +121,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // Register function const register = async ( - username: string, - password: string, + 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, diff --git a/frontend/src/hooks/useSettingsData.ts b/frontend/src/hooks/useSettingsData.ts index 9ceb755..d0e7fe6 100644 --- a/frontend/src/hooks/useSettingsData.ts +++ b/frontend/src/hooks/useSettingsData.ts @@ -10,6 +10,7 @@ interface RoutingConfig { enableGroupNameRoute: boolean; enableBearerAuth: boolean; bearerAuthKey: string; + skipAuth: boolean; } interface InstallConfig { @@ -46,6 +47,7 @@ export const useSettingsData = () => { enableGroupNameRoute: true, enableBearerAuth: false, bearerAuthKey: '', + skipAuth: false, }); const [tempRoutingConfig, setTempRoutingConfig] = useState({ @@ -99,6 +101,7 @@ export const useSettingsData = () => { enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true, enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false, bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '', + skipAuth: data.data.systemConfig.routing.skipAuth ?? false, }); } if (data.success && data.data?.systemConfig?.install) { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 5dd129e..55826f9 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -343,6 +343,8 @@ "bearerAuthKey": "Bearer Authentication Key", "bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token", "bearerAuthKeyPlaceholder": "Enter bearer authentication key", + "skipAuth": "Skip Authentication", + "skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)", "pythonIndexUrl": "Python Package Repository URL", "pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation", "pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 8b80292..0fdb12a 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -344,6 +344,8 @@ "bearerAuthKey": "Bearer 认证密钥", "bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥", "bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥", + "skipAuth": "免登录开关", + "skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)", "pythonIndexUrl": "Python 包仓库地址", "pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装", "pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple", diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 43500a2..1991841 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -85,7 +85,7 @@ const SettingsPage: React.FC = () => { })); }; - const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => { + const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => { // If enableBearerAuth is turned on and there's no key, generate one first if (key === 'enableBearerAuth' && value === true) { if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) { @@ -430,6 +430,18 @@ const SettingsPage: React.FC = () => { /> +
+
+

{t('settings.skipAuth')}

+

{t('settings.skipAuthDescription')}

+
+ handleRoutingConfigChange('skipAuth', checked)} + /> +
+ )} diff --git a/frontend/src/services/configService.ts b/frontend/src/services/configService.ts new file mode 100644 index 0000000..f7c5034 --- /dev/null +++ b/frontend/src/services/configService.ts @@ -0,0 +1,102 @@ +import { getApiUrl, getBasePath } from '../utils/runtime'; + +export interface SystemConfig { + routing?: { + enableGlobalRoute?: boolean; + enableGroupNameRoute?: boolean; + enableBearerAuth?: boolean; + bearerAuthKey?: string; + skipAuth?: boolean; + }; + install?: { + pythonIndexUrl?: string; + npmRegistry?: string; + }; + smartRouting?: { + enabled?: boolean; + dbUrl?: string; + openaiApiBaseUrl?: string; + openaiApiKey?: string; + openaiApiEmbeddingModel?: string; + }; +} + +export interface PublicConfigResponse { + success: boolean; + data?: { + skipAuth?: boolean; + }; + message?: string; +} + +export interface SystemConfigResponse { + success: boolean; + data?: { + systemConfig?: SystemConfig; + }; + message?: string; +} + +/** + * Get public configuration (skipAuth setting) without authentication + */ +export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => { + try { + const basePath = getBasePath(); + const response = await fetch(`${basePath}/public-config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const data: PublicConfigResponse = await response.json(); + return { skipAuth: data.data?.skipAuth === true }; + } + + return { skipAuth: false }; + } catch (error) { + console.debug('Failed to get public config:', error); + return { skipAuth: false }; + } +}; + +/** + * Get system configuration without authentication + * This function tries to get the system configuration first without auth, + * and if that fails (likely due to auth requirements), it returns null + */ +export const getSystemConfigPublic = async (): Promise => { + try { + const response = await fetch(getApiUrl('/settings'), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const data: SystemConfigResponse = await response.json(); + return data.data?.systemConfig || null; + } + + return null; + } catch (error) { + console.debug('Failed to get system config without auth:', error); + return null; + } +}; + +/** + * Check if authentication should be skipped based on system configuration + */ +export const shouldSkipAuth = async (): Promise => { + try { + const config = await getPublicConfig(); + return config.skipAuth; + } catch (error) { + console.debug('Failed to check skipAuth setting:', error); + return false; + } +}; diff --git a/frontend/src/services/logService.ts b/frontend/src/services/logService.ts index e491227..c93b3ef 100644 --- a/frontend/src/services/logService.ts +++ b/frontend/src/services/logService.ts @@ -15,13 +15,9 @@ export const fetchLogs = async (): Promise => { try { // Get authentication token const token = getToken(); - if (!token) { - throw new Error('Authentication token not found. Please log in.'); - } - const response = await fetch(getApiUrl('/logs'), { headers: { - 'x-auth-token': token, + 'x-auth-token': token || '', }, }); @@ -43,14 +39,10 @@ export const clearLogs = async (): Promise => { try { // Get authentication token const token = getToken(); - if (!token) { - throw new Error('Authentication token not found. Please log in.'); - } - const response = await fetch(getApiUrl('/logs'), { method: 'DELETE', headers: { - 'x-auth-token': token, + 'x-auth-token': token || '', }, }); @@ -84,12 +76,6 @@ export const useLogs = () => { // Get the authentication token const token = getToken(); - if (!token) { - setError(new Error('Authentication token not found. Please log in.')); - setLoading(false); - return; - } - // Connect to SSE endpoint with auth token in URL eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`)); diff --git a/frontend/src/services/toolService.ts b/frontend/src/services/toolService.ts index 4b50e99..2f18bf6 100644 --- a/frontend/src/services/toolService.ts +++ b/frontend/src/services/toolService.ts @@ -26,10 +26,6 @@ export const callTool = async ( ): Promise => { try { const token = getToken(); - if (!token) { - throw new Error('Authentication token not found. Please log in.'); - } - // Construct the URL with optional server parameter const url = server ? `/tools/call/${server}` : '/tools/call'; @@ -37,7 +33,7 @@ export const callTool = async ( method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token, + 'x-auth-token': token || '', // Include token for authentication Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing }, body: JSON.stringify({ @@ -81,15 +77,11 @@ export const toggleTool = async ( ): Promise<{ success: boolean; error?: string }> => { try { const token = getToken(); - if (!token) { - throw new Error('Authentication token not found. Please log in.'); - } - const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token, + 'x-auth-token': token || '', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ enabled }), @@ -123,18 +115,14 @@ export const updateToolDescription = async ( ): Promise<{ success: boolean; error?: string }> => { try { const token = getToken(); - if (!token) { - throw new Error('Authentication token not found. Please log in.'); - } - const response = await fetch( getApiUrl(`/servers/${serverName}/tools/${toolName}/description`), { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token, - Authorization: `Bearer ${token}`, + 'x-auth-token': token || '', + Authorization: `Bearer ${token || ''}`, }, body: JSON.stringify({ description }), }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 55720f9..862582f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -39,6 +39,14 @@ export default defineConfig({ target: 'http://localhost:3000', changeOrigin: true, }, + [`${basePath}/config`]: { + target: 'http://localhost:3000', + changeOrigin: true, + }, + [`${basePath}/public-config`]: { + target: 'http://localhost:3000', + changeOrigin: true, + }, }, }, }); diff --git a/src/controllers/configController.ts b/src/controllers/configController.ts index b7a1331..442581f 100644 --- a/src/controllers/configController.ts +++ b/src/controllers/configController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import config from '../config/index.js'; +import { loadSettings } from '../config/index.js'; /** * Get runtime configuration for frontend @@ -28,3 +29,31 @@ export const getRuntimeConfig = (req: Request, res: Response): void => { }); } }; + +/** + * Get public system configuration (only skipAuth setting) + * This endpoint doesn't require authentication to allow checking if auth should be skipped + */ +export const getPublicConfig = (req: Request, res: Response): void => { + try { + const settings = loadSettings(); + const skipAuth = settings.systemConfig?.routing?.skipAuth || false; + + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + + res.json({ + success: true, + data: { + skipAuth, + }, + }); + } catch (error) { + console.error('Error getting public config:', error); + res.status(500).json({ + success: false, + message: 'Failed to get public configuration', + }); + } +}; diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index 7aa36f1..ac7716a 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -498,7 +498,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => { (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean' && typeof routing.enableBearerAuth !== 'boolean' && - typeof routing.bearerAuthKey !== 'string')) && + typeof routing.bearerAuthKey !== 'string' && + typeof routing.skipAuth !== 'boolean')) && (!install || (typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) && (!smartRouting || @@ -523,6 +524,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => { enableGroupNameRoute: true, enableBearerAuth: false, bearerAuthKey: '', + skipAuth: false, }, install: { pythonIndexUrl: '', @@ -544,6 +546,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => { enableGroupNameRoute: true, enableBearerAuth: false, bearerAuthKey: '', + skipAuth: false, }; } @@ -580,6 +583,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => { if (typeof routing.bearerAuthKey === 'string') { settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey; } + + if (typeof routing.skipAuth === 'boolean') { + settings.systemConfig.routing.skipAuth = routing.skipAuth; + } } if (install) { diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index a9b5416..9777334 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -1,11 +1,45 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import { loadSettings } from '../config/index.js'; // Default secret key - in production, use an environment variable const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'; +const validateBearerAuth = (req: Request, routingConfig: any): boolean => { + if (!routingConfig.enableBearerAuth) { + return false; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + return authHeader.substring(7) === routingConfig.bearerAuthKey; +}; + // Middleware to authenticate JWT token export const auth = (req: Request, res: Response, next: NextFunction): void => { + // Check if authentication is disabled globally + const routingConfig = loadSettings().systemConfig?.routing || { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: '', + skipAuth: false, + }; + + if (routingConfig.skipAuth) { + next(); + return; + } + + // Check if bearer auth is enabled and validate it + if (validateBearerAuth(req, routingConfig)) { + next(); + return; + } + // Get token from header or query parameter const headerToken = req.header('x-auth-token'); const queryToken = req.query.token as string; @@ -20,11 +54,11 @@ export const auth = (req: Request, res: Response, next: NextFunction): void => { // 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/routes/index.ts b/src/routes/index.ts index e7f2f54..a51a718 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -34,7 +34,7 @@ import { } from '../controllers/marketController.js'; import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; -import { getRuntimeConfig } from '../controllers/configController.js'; +import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js'; import { callTool } from '../controllers/toolController.js'; import { auth } from '../middlewares/auth.js'; @@ -116,6 +116,9 @@ export const initRoutes = (app: express.Application): void => { // Runtime configuration endpoint (no auth required for frontend initialization) app.get(`${config.basePath}/config`, getRuntimeConfig); + // Public configuration endpoint (no auth required to check skipAuth setting) + app.get(`${config.basePath}/public-config`, getPublicConfig); + app.use(`${config.basePath}/api`, router); }; diff --git a/src/types/index.ts b/src/types/index.ts index 00bf1f9..651476b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -87,6 +87,7 @@ export interface McpSettings { enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes bearerAuthKey?: string; // The bearer auth key to validate against + skipAuth?: boolean; // Controls whether authentication is required for frontend and API access }; install?: { pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)