mirror of
https://github.com/samanhappy/mcphub.git
synced 2026-01-01 12:18:39 -05:00
feat: Add OAuth 2.0 / OIDC SSO login support
- Add OAuth SSO type definitions (OAuthSSOProvider, OAuthSSOConfig, IOAuthLink) - Add oauthSSO field to SystemConfig for provider configuration - Update IUser interface to support OAuth-linked accounts - Create OAuth SSO service with provider management and token exchange - Add SSO controller with login initiation and callback handling - Update frontend login page with SSO provider buttons - Add SSOCallbackPage for handling OAuth redirects - Update database entities and DAOs for OAuth link storage - Add i18n translations for SSO-related UI elements - Add comprehensive unit tests for OAuth SSO service Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { SettingsProvider } from './contexts/SettingsContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SSOCallbackPage from './pages/SSOCallbackPage';
|
||||
import DashboardPage from './pages/Dashboard';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
@@ -35,6 +36,7 @@ function App() {
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/sso-callback" element={<SSOCallbackPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getToken } from '../services/authService';
|
||||
import { getToken, getSSOConfig, initiateSSOLogin } from '../services/authService';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
|
||||
import { SSOProvider } from '../types';
|
||||
|
||||
const sanitizeReturnUrl = (value: string | null): string | null => {
|
||||
if (!value) {
|
||||
@@ -29,6 +30,44 @@ const sanitizeReturnUrl = (value: string | null): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
// Provider icons (SVG)
|
||||
const ProviderIcon: React.FC<{ type: string; className?: string }> = ({ type, className = 'w-5 h-5' }) => {
|
||||
switch (type) {
|
||||
case 'google':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
case 'github':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'microsoft':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill="#F25022" d="M1 1h10v10H1z"/>
|
||||
<path fill="#00A4EF" d="M1 13h10v10H1z"/>
|
||||
<path fill="#7FBA00" d="M13 1h10v10H13z"/>
|
||||
<path fill="#FFB900" d="M13 13h10v10H13z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
// Generic OAuth/OIDC icon
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -36,6 +75,9 @@ const LoginPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||
const [allowLocalAuth, setAllowLocalAuth] = useState(true);
|
||||
const { login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,6 +86,17 @@ const LoginPage: React.FC = () => {
|
||||
return sanitizeReturnUrl(params.get('returnUrl'));
|
||||
}, [location.search]);
|
||||
|
||||
// Load SSO configuration on mount
|
||||
useEffect(() => {
|
||||
const loadSSOConfig = async () => {
|
||||
const config = await getSSOConfig();
|
||||
setSsoEnabled(config.enabled);
|
||||
setSsoProviders(config.providers);
|
||||
setAllowLocalAuth(config.allowLocalAuth);
|
||||
};
|
||||
loadSSOConfig();
|
||||
}, []);
|
||||
|
||||
const isServerUnavailableError = useCallback((message?: string) => {
|
||||
if (!message) return false;
|
||||
const normalized = message.toLowerCase();
|
||||
@@ -137,6 +190,10 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSOLogin = (providerId: string) => {
|
||||
initiateSSOLogin(providerId, returnUrl || undefined);
|
||||
};
|
||||
|
||||
const handleCloseWarning = () => {
|
||||
setShowDefaultPasswordWarning(false);
|
||||
redirectAfterLogin();
|
||||
@@ -193,58 +250,100 @@ const LoginPage: React.FC = () => {
|
||||
<div className="login-card relative w-full rounded-2xl border border-white/10 bg-white/60 p-8 shadow-xl backdrop-blur-md transition dark:border-white/10 dark:bg-gray-900/60">
|
||||
<div className="absolute -top-24 right-12 h-40 w-40 -translate-y-6 rounded-full bg-indigo-500/30 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-12 h-40 w-40 translate-y-6 rounded-full bg-cyan-500/20 blur-3xl" />
|
||||
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-box rounded border border-red-500/20 bg-red-500/10 p-2 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="login-button btn-primary group relative flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||
</button>
|
||||
{/* SSO Buttons */}
|
||||
{ssoEnabled && ssoProviders.length > 0 && (
|
||||
<div className="space-y-3 mb-6">
|
||||
{ssoProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => handleSSOLogin(provider.id)}
|
||||
className="sso-button group relative flex w-full items-center justify-center gap-3 rounded-md border border-gray-300/60 bg-white/80 px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:border-gray-400/60 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-gray-600/60 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700/80"
|
||||
>
|
||||
<ProviderIcon type={provider.type} />
|
||||
<span>{t('auth.continueWith', { provider: provider.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Divider - only show if local auth is also allowed */}
|
||||
{allowLocalAuth && (
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300/60 dark:border-gray-600/60" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white/60 text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
|
||||
{t('auth.orContinueWith')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Local auth form - only show if allowed */}
|
||||
{allowLocalAuth && (
|
||||
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-box rounded border border-red-500/20 bg-red-500/10 p-2 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="login-button btn-primary group relative flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Show message if only SSO is available and no providers configured */}
|
||||
{!allowLocalAuth && ssoProviders.length === 0 && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
{t('auth.noLoginMethodsAvailable')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
108
frontend/src/pages/SSOCallbackPage.tsx
Normal file
108
frontend/src/pages/SSOCallbackPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { handleSSOToken, getCurrentUser } from '../services/authService';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* SSO Callback Page
|
||||
* Handles the redirect from OAuth SSO callback, extracts token, and redirects to destination
|
||||
*/
|
||||
const SSOCallbackPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { auth } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const returnUrl = params.get('returnUrl') || '/';
|
||||
const errorParam = params.get('error');
|
||||
|
||||
// Handle OAuth errors
|
||||
if (errorParam) {
|
||||
setError(errorParam);
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle successful SSO login
|
||||
if (token) {
|
||||
try {
|
||||
// Store the token
|
||||
handleSSOToken(token);
|
||||
|
||||
// Verify the token by fetching current user
|
||||
const response = await getCurrentUser();
|
||||
if (response.success) {
|
||||
// Redirect to the return URL or dashboard
|
||||
if (returnUrl.startsWith('/oauth/authorize')) {
|
||||
// For OAuth authorize flow, pass the token
|
||||
const url = new URL(returnUrl, window.location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
window.location.assign(`${url.pathname}${url.search}`);
|
||||
} else {
|
||||
navigate(returnUrl);
|
||||
}
|
||||
} else {
|
||||
setError(t('auth.ssoTokenInvalid'));
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SSO callback error:', err);
|
||||
setError(t('auth.ssoCallbackError'));
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
// No token provided
|
||||
setError(t('auth.ssoNoToken'));
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Only handle callback if not already authenticated
|
||||
if (!auth.isAuthenticated) {
|
||||
handleCallback();
|
||||
} else {
|
||||
// Already authenticated, redirect to home
|
||||
navigate('/');
|
||||
}
|
||||
}, [location.search, navigate, auth.isAuthenticated, t]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
{error ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-red-600 dark:text-red-400 text-lg font-medium">
|
||||
{error}
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t('auth.redirectingToLogin')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
{t('auth.ssoProcessing')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSOCallbackPage;
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LoginCredentials,
|
||||
RegisterCredentials,
|
||||
ChangePasswordCredentials,
|
||||
SSOConfig,
|
||||
} from '../types';
|
||||
import { apiPost, apiGet } from '../utils/fetchInterceptor';
|
||||
import { getToken, setToken, removeToken } from '../utils/interceptors';
|
||||
@@ -10,6 +11,35 @@ import { getToken, setToken, removeToken } from '../utils/interceptors';
|
||||
// Export token management functions
|
||||
export { getToken, setToken, removeToken };
|
||||
|
||||
// Get SSO configuration
|
||||
export const getSSOConfig = async (): Promise<SSOConfig> => {
|
||||
try {
|
||||
const response = await apiGet<{ success: boolean; data: SSOConfig }>('/auth/sso/config');
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
return { enabled: false, providers: [], allowLocalAuth: true };
|
||||
} catch (error) {
|
||||
console.error('Get SSO config error:', error);
|
||||
return { enabled: false, providers: [], allowLocalAuth: true };
|
||||
}
|
||||
};
|
||||
|
||||
// Initiate SSO login (redirects to provider)
|
||||
export const initiateSSOLogin = (providerId: string, returnUrl?: string): void => {
|
||||
const basePath = import.meta.env.VITE_API_BASE_PATH || '';
|
||||
let url = `${basePath}/api/auth/sso/${providerId}`;
|
||||
if (returnUrl) {
|
||||
url += `?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Handle SSO callback token (called from SSO callback page)
|
||||
export const handleSSOToken = (token: string): void => {
|
||||
setToken(token);
|
||||
};
|
||||
|
||||
// Login user
|
||||
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
|
||||
@@ -329,6 +329,19 @@ export interface IUser {
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
// OAuth SSO types
|
||||
export interface SSOProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SSOConfig {
|
||||
enabled: boolean;
|
||||
providers: SSOProvider[];
|
||||
allowLocalAuth: boolean;
|
||||
}
|
||||
|
||||
// User management types
|
||||
export interface User {
|
||||
username: string;
|
||||
|
||||
Reference in New Issue
Block a user