Просмотр исходного кода

feat: 接入统一认证平台 SSO 前端

- 新增 SSO 回调页面 /auth/callback (提取 code、换码、保存 token)
- 请求拦截器添加 Bearer token,响应拦截器处理 X-New-Token 和 401
- 登录页面改为统一认证平台登录,隐藏本地用户名密码登录
- getInitialState 跳过 SSO 回调页面的用户信息获取
- 新增 /auth/callback 路由
kinglee 1 неделя назад
Родитель
Сommit
4d05e2cfe9
5 измененных файлов с 147 добавлено и 134 удалено
  1. 8 0
      config/routes.ts
  2. 4 1
      src/app.tsx
  3. 85 0
      src/pages/auth/callback/index.tsx
  4. 23 130
      src/pages/login/components/login-form.tsx
  5. 27 3
      src/request-config.tsx

+ 8 - 0
config/routes.ts

@@ -367,6 +367,14 @@ const baseRoutes = [
     hideInMenu: true,
     component: './login'
   },
+  {
+    name: 'sso-callback',
+    path: '/auth/callback',
+    key: 'sso-callback',
+    layout: false,
+    hideInMenu: true,
+    component: './auth/callback'
+  },
   {
     name: '404',
     path: '*',

+ 4 - 1
src/app.tsx

@@ -121,7 +121,10 @@ export async function getInitialState(): Promise<{
 
   getAppVersionInfo();
 
-  if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
+  // SSO 回调页面不需要获取用户信息,等待换码完成
+  const isSSOCallback = location.pathname.startsWith('/auth/callback');
+
+  if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname) && !isSSOCallback) {
     const userInfo = await fetchUserInfo();
     checkDefaultPage(userInfo);
     return {

+ 85 - 0
src/pages/auth/callback/index.tsx

@@ -0,0 +1,85 @@
+import { useEffect, useState } from 'react';
+import { Spin, message } from 'antd';
+import { history } from 'umi';
+import { request } from 'umi';
+import { DEFAULT_ENTER_PAGE } from '@/config/settings';
+
+/**
+ * SSO 回调页面
+ * 从 URL 参数中提取 code,调用后端换码接口获取本地 JWT
+ * URL: /auth/callback?code=xxxxxx
+ */
+const SSOCallback = () => {
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const exchangeCode = async () => {
+      const params = new URLSearchParams(window.location.search);
+      const code = params.get('code');
+      const error = params.get('error');
+
+      // 检查是否有 SSO 错误
+      if (error) {
+        const errorDesc = params.get('error_description') || error;
+        message.error(`SSO 登录失败: ${errorDesc}`);
+        history.push(DEFAULT_ENTER_PAGE.login);
+        return;
+      }
+
+      if (!code) {
+        message.error('缺少授权码');
+        history.push(DEFAULT_ENTER_PAGE.login);
+        return;
+      }
+
+      try {
+        const result = await request('/auth/oauth/exchange-code', {
+          method: 'POST',
+          data: { code },
+          errorExhaustive: false,
+        });
+
+        if (result.code === '000000' && result.data) {
+          // 保存 Token 到 localStorage
+          localStorage.setItem('token', result.data.token);
+          if (result.data.refresh_token) {
+            localStorage.setItem('refresh_token', result.data.refresh_token);
+          }
+          localStorage.setItem('user', JSON.stringify(result.data.user));
+
+          message.success('登录成功');
+
+          // 跳转首页
+          history.push(DEFAULT_ENTER_PAGE.user);
+        } else {
+          message.error(result.message || '登录失败');
+          history.push(DEFAULT_ENTER_PAGE.login);
+        }
+      } catch (err: any) {
+        const errorMsg = err?.response?.data?.message || err?.message || '换码失败';
+        message.error(`登录失败: ${errorMsg}`);
+        history.push(DEFAULT_ENTER_PAGE.login);
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    exchangeCode();
+  }, []);
+
+  return (
+    <div style={{
+      display: 'flex',
+      justifyContent: 'center',
+      alignItems: 'center',
+      height: '100vh',
+      flexDirection: 'column',
+      gap: 16,
+    }}>
+      <Spin size="large" />
+      <div style={{ color: '#666' }}>正在登录中...</div>
+    </div>
+  );
+};
+
+export default SSOCallback;

+ 23 - 130
src/pages/login/components/login-form.tsx

@@ -1,15 +1,12 @@
 import { userAtom } from '@/atoms/user';
-import { useIntl, useModel } from '@umijs/max';
-import { Button, Divider, Form, Spin, message } from 'antd';
+import { useModel } from '@umijs/max';
+import { Button, Spin, message } from 'antd';
 import { createStyles } from 'antd-style';
 import { useAtom } from 'jotai';
-import { useMemo, useState } from 'react';
+import { useState } from 'react';
 import { flushSync } from 'react-dom';
 import styled from 'styled-components';
-import { useLocalAuth } from '../hooks/use-local-auth';
-import { useSSOAuth } from '../hooks/use-sso-auth';
 import { checkDefaultPage } from '../utils';
-import LocalUserForm from './local-user-form';
 
 const SpinContainer = styled.div`
   display: flex;
@@ -33,14 +30,6 @@ const Buttons = styled.div`
   margin-top: 52px;
 `;
 
-const BackButton = styled(Button).attrs({
-  type: 'link',
-  size: 'small',
-  block: true
-})`
-  margin-top: 20px;
-`;
-
 const ButtonWrapper = styled(Button).attrs({
   type: 'primary',
   block: true
@@ -54,13 +43,6 @@ const ButtonText = styled.span`
   gap: 8px;
 `;
 
-const DividerWrapper = styled(Divider)`
-  margin-block: 24px !important;
-  .ant-divider-inner-text {
-    color: var(--ant-color-text-secondary);
-  }
-`;
-
 const useStyles = createStyles(({ token, css }) => ({
   errorMessage: css`
     display: flex;
@@ -84,15 +66,16 @@ const useStyles = createStyles(({ token, css }) => ({
   `
 }));
 
+/**
+ * 修改后的登录表单:隐藏本地用户名密码登录,仅显示 SSO 登录按钮
+ * 点击后直接跳转至 SSO 授权页 (/auth/sso/authorize?redirect=true)
+ */
 const LoginForm = () => {
   const [messageApi, contextHolder] = message.useMessage();
   const { styles } = useStyles();
   const [, setUserInfo] = useAtom(userAtom);
   const { initialState, setInitialState } = useModel('@@initialState') || {};
   const [authError, setAuthError] = useState<Error | null>(null);
-  const intl = useIntl();
-  const [form] = Form.useForm();
-  const [isPassword, setIsPassword] = useState(false);
   const [loading, setLoading] = useState(false);
 
   const renderWelCome = () => {
@@ -131,113 +114,35 @@ const LoginForm = () => {
       duration: 5,
       content: (
         <div className={styles.errorMessage}>
-          <div className="title">
-            {intl.formatMessage({ id: 'common.login.auth.failed' })}
-          </div>
+          <div className="title">登录失败</div>
           <div className="message">{error?.message || 'Unknown error'}</div>
         </div>
       )
     });
   };
 
-  // local user authentication
-  const { handleLogin, submitLoading } = useLocalAuth({
-    fetchUserInfo,
-    form,
-    onSuccess: async (userInfo) => {
-      setUserInfo(userInfo);
-      if (!userInfo?.require_password_change) {
-        gotoDefaultPage(userInfo);
-      }
-    },
-
-    onError: (error) => {
-      // gpustack handle in the interceptor
-    }
-  });
-
-  // SSO hook
-  const SSOAuth = useSSOAuth({
-    fetchUserInfo,
-    onSuccess: (userInfo) => {
-      setUserInfo(userInfo);
-      gotoDefaultPage({});
-    },
-    onLoading: (loading) => {
-      setLoading(loading);
-    },
-    onError: handleOnError
-  });
-
-  const handleLoginWithPassword = () => {
-    setIsPassword(true);
-  };
-
-  const handleLoginWithThirdParty = () => {
-    if (SSOAuth.options.oidc) {
-      SSOAuth.loginWithOIDC();
-    } else if (SSOAuth.options.saml) {
-      SSOAuth.loginWithSAML();
-    }
+  /**
+   * SSO 登录:跳转至后端 SSO 授权 URL
+   * 后端会 302 重定向到统一认证平台登录页
+   */
+  const handleSSOLogin = () => {
     setLoading(true);
     setAuthError(null);
+    // 直接跳转至 SSO 授权页
+    window.location.href = '/auth/sso/authorize?redirect=true';
   };
 
-  const hasThirdPartyLogin = useMemo(() => {
-    return SSOAuth.options.oidc || SSOAuth.options.saml;
-  }, [SSOAuth.options]);
-
-  const isThirdPartyAuthHandling = useMemo(() => {
-    return loading && !authError;
-  }, [loading, authError]);
-
-  const renderLoginButtons = () => {
-    // do not render login buttons if using password login or no third-party login
-    if (!hasThirdPartyLogin || isPassword) return null;
-
-    return (
-      <Buttons>
-        {SSOAuth.options.oidc && (
-          <ButtonWrapper onClick={SSOAuth.loginWithOIDC}>
-            <ButtonText>
-              {intl.formatMessage(
-                { id: 'common.external.login' },
-                { type: 'SSO' }
-              )}
-            </ButtonText>
-          </ButtonWrapper>
-        )}
-        {SSOAuth.options.saml && (
-          <ButtonWrapper onClick={SSOAuth.loginWithSAML}>
-            <ButtonText>
-              {intl.formatMessage(
-                { id: 'common.external.login' },
-                { type: 'SSO' }
-              )}
-            </ButtonText>
-          </ButtonWrapper>
-        )}
-        <Button type="link" block onClick={handleLoginWithPassword}>
-          <ButtonText>
-            {intl.formatMessage({ id: 'common.login.password' })}
-          </ButtonText>
-        </Button>
-      </Buttons>
-    );
-  };
+  const isSSOAuthHandling = loading && !authError;
 
   return (
     <div>
       {contextHolder}
       <div>
-        {isThirdPartyAuthHandling ? (
+        {isSSOAuthHandling ? (
           <SpinContainer>
             {renderWelCome()}
             <div className="spin">
-              <Spin
-                tip={intl.formatMessage({ id: 'common.login.auth' })}
-                size="middle"
-              >
+              <Spin tip="正在跳转至统一认证平台..." size="middle">
                 <div style={{ width: 300 }}></div>
               </Spin>
             </div>
@@ -245,23 +150,11 @@ const LoginForm = () => {
         ) : (
           <>
             {renderWelCome()}
-            {renderLoginButtons()}
-            {(!hasThirdPartyLogin || isPassword) && (
-              <LocalUserForm
-                handleLogin={handleLogin}
-                form={form}
-                loading={submitLoading}
-                loginOption={SSOAuth.options}
-              />
-            )}
-            {hasThirdPartyLogin && isPassword && (
-              <BackButton onClick={handleLoginWithThirdParty}>
-                {intl.formatMessage(
-                  { id: 'common.external.login' },
-                  { type: 'SSO' }
-                )}
-              </BackButton>
-            )}
+            <Buttons>
+              <ButtonWrapper onClick={handleSSOLogin}>
+                <ButtonText>统一认证平台登录</ButtonText>
+              </ButtonWrapper>
+            </Buttons>
           </>
         )}
       </div>

+ 27 - 3
src/request-config.tsx

@@ -7,7 +7,14 @@ import ErrorMessageContent from './pages/_components/error-message-content';
 import { extraRequestInterceptors } from './request.extensions';
 
 //  these APIs do not via the GPUSTACK_API_BASE_URL
-const NoBaseURLAPIs = ['/auth', '/v1', '/version', '/proxy', '/update'];
+const NoBaseURLAPIs = ['/auth', '/v1', '/version', '/proxy', '/update', '/api/oauth'];
+
+/**
+ * 从 localStorage 获取 SSO token
+ */
+function getSSOToken(): string | null {
+  return localStorage.getItem('token');
+}
 
 export const requestConfig: RequestConfig = {
   errorConfig: {
@@ -27,6 +34,10 @@ export const requestConfig: RequestConfig = {
         });
       }
       if (response?.status === 401) {
+        // Token 过期或无效,清除本地状态并跳转登录
+        localStorage.removeItem('token');
+        localStorage.removeItem('refresh_token');
+        localStorage.removeItem('user');
         clearAtomStorage(userAtom);
 
         history.push(DEFAULT_ENTER_PAGE.login, { replace: true });
@@ -37,8 +48,17 @@ export const requestConfig: RequestConfig = {
     (url, options) => {
       if (NoBaseURLAPIs.some((api) => url.startsWith(api))) {
         options.baseURL = '';
-        return { url, options };
       }
+
+      // 携带 SSO token
+      const token = getSSOToken();
+      if (token) {
+        options.headers = {
+          ...options.headers,
+          Authorization: `Bearer ${token}`,
+        };
+      }
+
       return { url, options };
     },
     // Build-time tooling can plug additional interceptors via
@@ -47,7 +67,11 @@ export const requestConfig: RequestConfig = {
   ],
   responseInterceptors: [
     (response) => {
-      // to do something
+      // Token 滑动过期: 后端通过 X-New-Token 响应头返回新 token
+      const newToken = response.headers?.['x-new-token'];
+      if (newToken) {
+        localStorage.setItem('token', newToken);
+      }
       return response;
     }
   ]