From d6ff547f20180dab886ed4e29f5807c7f50e9abe Mon Sep 17 00:00:00 2001 From: "guangfei.zhao" Date: Fri, 10 Oct 2025 18:25:20 +0800 Subject: [PATCH] feat(auth): implement secure authentication flow with RSA encryption - Add jsencrypt and js-base64 dependencies for RSA encryption - Create AuthGuard component for route protection - Implement encryption utility for password security - Refactor login page with tabbed interface and form validation - Add login hooks for authentication state management - Update user service to handle encrypted passwords --- .env | 4 + package.json | 2 + pnpm-lock.yaml | 16 ++ src/App.tsx | 6 +- src/components/AuthGuard.tsx | 52 +++++ src/hooks/login-hooks.ts | 433 +++++++++++++++++++++++++++++++++++ src/pages/Login.tsx | 244 ++++++++++++-------- src/services/user_service.ts | 2 +- src/utils/encryption.ts | 36 +++ src/utils/request.ts | 21 +- 10 files changed, 697 insertions(+), 119 deletions(-) create mode 100644 src/components/AuthGuard.tsx create mode 100644 src/hooks/login-hooks.ts create mode 100644 src/utils/encryption.ts diff --git a/.env b/.env index c074e5b..ff8e5ad 100644 --- a/.env +++ b/.env @@ -1 +1,5 @@ VITE_API_BASE_URL = http://150.158.121.95 + +VITE_RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArq9XTUSeYr2+N1h3Afl/z8Dse/2yD0ZGrKwx+EEEcdsBLca9Ynmx3nIB5obmLlSfmskLpBo0UACBmB5rEjBp2Q2f3AG3Hjd4B+gNCG6BDaawuDlgANIhGnaTLrIqWrrcm4EMzJOnAOI1fgzJRsOOUEfaS318Eq9OVO3apEyCCt0lOQK6PuksduOjVxtltDav+guVAA068NrPYmRNabVKRNLJpL8w4D44sfth5RvZ3q9t+6RTArpEtc5sh5ChzvqPOzKGMXW83C95TxmXqpbK6olN4RevSfVjEAgCydH6HN6OhtOQEcnrU97r9H0iZOWwbw3pVrZiUkuRD1R56Wzs2wIDAQAB +-----END PUBLIC KEY-----" \ No newline at end of file diff --git a/package.json b/package.json index 04856e9..a5c18c8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "dayjs": "^1.11.18", "i18next": "^25.5.3", "i18next-browser-languagedetector": "^8.2.0", + "js-base64": "^3.7.8", + "jsencrypt": "^3.5.4", "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6d77df..8df5acd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,12 @@ importers: i18next-browser-languagedetector: specifier: ^8.2.0 version: 8.2.0 + js-base64: + specifier: ^3.7.8 + version: 3.7.8 + jsencrypt: + specifier: ^3.5.4 + version: 3.5.4 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -1217,6 +1223,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} @@ -1228,6 +1237,9 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsencrypt@3.5.4: + resolution: {integrity: sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2769,6 +2781,8 @@ snapshots: isexe@2.0.0: {} + js-base64@3.7.8: {} + js-cookie@3.0.5: {} js-tokens@4.0.0: {} @@ -2777,6 +2791,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsencrypt@3.5.4: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} diff --git a/src/App.tsx b/src/App.tsx index 28506d3..abf2297 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,10 @@ import { CssBaseline, ThemeProvider } from '@mui/material'; import { theme } from './theme'; import AppRoutes from './routes'; import SnackbarProvider from './components/Provider/SnackbarProvider'; +import AuthGuard from './components/AuthGuard'; import './locales'; +import './utils/request' /** * 封装MaterialUIApp,将主题、基础样式和路由包裹起来 @@ -15,7 +17,9 @@ function MaterialUIApp() { - + + + diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx new file mode 100644 index 0000000..8fd34f1 --- /dev/null +++ b/src/components/AuthGuard.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { CircularProgress, Box } from '@mui/material'; + +interface AuthGuardProps { + children: React.ReactNode; +} + +const AuthGuard: React.FC = ({ children }) => { + const location = useLocation(); + const navigate = useNavigate(); + + // 直接从localStorage检查token,避免React状态更新的延迟 + const token = localStorage.getItem('token'); + const isAuthenticated = !!token; + const isLoginPage = location.pathname === '/login'; + + useEffect(() => { + if (isAuthenticated && isLoginPage) { + // 已登录用户访问登录页,重定向到首页 + navigate('/', { replace: true }); + } else if (!isAuthenticated && !isLoginPage) { + // 未登录用户访问其他页面,重定向到登录页并保存当前路径 + const redirectUrl = encodeURIComponent(location.pathname + location.search); + navigate(`/login?redirect=${redirectUrl}`, { replace: true }); + } + }, [isAuthenticated, isLoginPage, location, navigate]); + + // 如果是登录页面,直接渲染 + if (isLoginPage) { + return <>{children}; + } + + // 如果未认证,显示加载状态(实际上会被重定向) + if (!isAuthenticated) { + return ( + + + + ); + } + + // 已认证,渲染子组件 + return <>{children}; +}; + +export default AuthGuard; \ No newline at end of file diff --git a/src/hooks/login-hooks.ts b/src/hooks/login-hooks.ts new file mode 100644 index 0000000..8ad2126 --- /dev/null +++ b/src/hooks/login-hooks.ts @@ -0,0 +1,433 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import userService from '../services/user_service'; +import { rsaPsw } from '../utils/encryption'; + +/** + * OAuth回调处理Hook + */ +export const useOAuthCallback = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const error = searchParams.get('error'); + const newSearchParams: URLSearchParams = useMemo( + () => new URLSearchParams(searchParams.toString()), + [searchParams], + ); + + useEffect(() => { + if (error) { + // 显示错误信息(这里可以集成你的消息提示组件) + console.error('OAuth Error:', error); + setTimeout(() => { + navigate('/login'); + newSearchParams.delete('error'); + setSearchParams(newSearchParams); + }, 1000); + return; + } + + const auth = searchParams.get('auth'); + if (auth) { + // 存储认证信息 + localStorage.setItem('token', auth); + newSearchParams.delete('auth'); + setSearchParams(newSearchParams); + navigate('/'); + } + }, [ + error, + searchParams, + newSearchParams, + navigate, + setSearchParams, + ]); + + return searchParams.get('auth'); +}; + +/** + * 认证状态管理Hook + */ +export const useAuth = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [userInfo, setUserInfo] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // OAuth回调处理 + const auth = useOAuthCallback(); + + // 检查认证状态 + const checkAuthStatus = () => { + try { + const storedToken = localStorage.getItem('token'); + const storedUserInfo = localStorage.getItem('userInfo'); + + if (storedToken) { + setToken(storedToken); + setIsAuthenticated(true); + + if (storedUserInfo) { + setUserInfo(JSON.parse(storedUserInfo)); + } + } else { + setToken(null); + setIsAuthenticated(false); + setUserInfo(null); + } + } catch (error) { + console.error('Error checking auth status:', error); + setToken(null); + setIsAuthenticated(false); + setUserInfo(null); + } finally { + setIsLoading(false); + } + }; + + // 登出功能 + const logout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('userInfo'); + setToken(null); + setIsAuthenticated(false); + setUserInfo(null); + navigate('/login'); + }; + + // 更新认证状态 + const updateAuthStatus = (newToken: string, newUserInfo: any) => { + setToken(newToken); + setUserInfo(newUserInfo); + setIsAuthenticated(true); + localStorage.setItem('token', newToken); + localStorage.setItem('userInfo', JSON.stringify(newUserInfo)); + }; + + // 带参数重定向到登录页面 + const redirectToLogin = (returnUrl?: string) => { + const currentPath = returnUrl || location.pathname + location.search; + if (currentPath !== '/login') { + navigate(`/login?redirect=${encodeURIComponent(currentPath)}`); + } else { + navigate('/login'); + } + }; + + // 重定向到指定页面或首页 + const redirectAfterLogin = () => { + const redirectUrl = searchParams.get('redirect'); + if (redirectUrl) { + navigate(decodeURIComponent(redirectUrl)); + } else { + navigate('/'); + } + }; + + useEffect(() => { + checkAuthStatus(); + }, [auth]); + + return { + isAuthenticated, + userInfo, + token, + isLoading, + checkAuthStatus, + logout, + updateAuthStatus, + redirectToLogin, + redirectAfterLogin, + }; +}; + +export interface LoginFormData { + email: string; + password: string; + rememberEmail?: boolean; +} + +export interface RegisterFormData { + email: string; + password: string; + nickname: string; +} + +/** + * 登录Hook + */ +export const useLogin = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + + const login = async (data: LoginFormData) => { + setIsLoading(true); + + try { + // RSA加密密码 + const encryptedPassword = rsaPsw(data.password); + const response = await userService.login({ + email: data.email.trim(), + password: encryptedPassword + }); + + const { data: res = {} } = response; + const { code, message } = res; + + if (code === 0) { + // 登录成功,处理token和用户信息 + const { data: userData } = res; + const token = userData.access_token; + const userInfo = { + ...userData, + avatar: userData.avatar, + name: userData.nickname, + email: userData.email, + }; + + // 同步更新localStorage + localStorage.setItem('token', token); + localStorage.setItem('userInfo', JSON.stringify(userInfo)); + + // 直接进行重定向,不依赖React状态更新 + const redirectUrl = searchParams.get('redirect'); + if (redirectUrl) { + navigate(decodeURIComponent(redirectUrl), { replace: true }); + } else { + navigate('/', { replace: true }); + } + + return { success: true }; + } else { + // 登录失败 + return { + success: false, + error: message || t('login.loginFailed') + }; + } + } catch (error: any) { + // 处理网络错误或其他异常 + return { + success: false, + error: error.message || t('login.networkError') + }; + } finally { + setIsLoading(false); + } + }; + + return { login, isLoading }; +}; + +/** + * 注册Hook + */ +export const useRegister = () => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + + const register = async (data: RegisterFormData) => { + setIsLoading(true); + + try { + // RSA加密密码 + const encryptedPassword = rsaPsw(data.password); + + const response = await userService.register({ + nickname: data.nickname, + email: data.email.trim(), + password: encryptedPassword + }); + + const { data: res = {} } = response; + const { code, message } = res; + + if (code === 0) { + // 注册成功 + return { + success: true, + email: data.email + }; + } else { + // 注册失败 + return { + success: false, + error: message || t('login.registerFailed') + }; + } + } catch (error: any) { + // 处理网络错误或其他异常 + return { + success: false, + error: error.message || t('login.networkError') + }; + } finally { + setIsLoading(false); + } + }; + + return { register, isLoading }; +}; + +/** + * 登录表单管理Hook + */ +export const useLoginForm = () => { + const { t } = useTranslation(); + const [tabValue, setTabValue] = useState(0); // 0: 登录, 1: 注册 + const [error, setError] = useState(''); + + // 登录表单 + const loginForm = useForm({ + defaultValues: { + email: '', + password: '', + rememberEmail: false + } + }); + + // 注册表单 + const registerForm = useForm({ + defaultValues: { + email: '', + password: '', + nickname: '' + } + }); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + setError(''); + // 清空表单错误 + loginForm.clearErrors(); + registerForm.clearErrors(); + }; + + const switchToLogin = (email?: string) => { + setTabValue(0); + setError(''); + if (email) { + loginForm.setValue('email', email); + } + }; + + const setFormError = (errorMessage: string) => { + setError(errorMessage); + }; + + const clearError = () => { + setError(''); + }; + + return { + // 表单状态 + tabValue, + setTabValue, + error, + loginForm, + registerForm, + + // 表单操作 + handleTabChange, + switchToLogin, + setFormError, + clearError, + + // 表单验证规则 + loginValidation: { + email: { + required: t('login.emailPlaceholder'), + pattern: { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + message: t('login.emailInvalid') + } + }, + password: { + required: t('login.passwordPlaceholder'), + minLength: { + value: 6, + message: t('login.passwordMinLength') + } + } + }, + registerValidation: { + nickname: { + required: t('login.nicknamePlaceholder'), + minLength: { + value: 2, + message: t('login.nicknameMinLength') + } + }, + email: { + required: t('login.emailPlaceholder'), + pattern: { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + message: t('login.emailInvalid') + } + }, + password: { + required: t('login.passwordPlaceholder'), + minLength: { + value: 8, + message: t('login.passwordMinLength') + } + } + } + }; +}; + +/** + * 登录页面主Hook - 整合所有登录相关逻辑 + */ +export const useLoginPage = () => { + const loginHook = useLogin(); + const registerHook = useRegister(); + const formHook = useLoginForm(); + + const handleLogin = async (data: LoginFormData) => { + formHook.clearError(); + const result = await loginHook.login(data); + + if (!result.success && result.error) { + formHook.setFormError(result.error); + } + + return result; + }; + + const handleRegister = async (data: RegisterFormData) => { + formHook.clearError(); + const result = await registerHook.register(data); + + if (result.success) { + // 注册成功,切换到登录表单并填入邮箱 + formHook.registerForm.reset(); + formHook.switchToLogin(result.email); + } else if (result.error) { + formHook.setFormError(result.error); + } + + return result; + }; + + const isSubmitting = loginHook.isLoading || registerHook.isLoading; + + return { + // 表单相关 + ...formHook, + + // 提交处理 + handleLogin, + handleRegister, + isSubmitting, + }; +}; \ No newline at end of file diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d8b6952..2a6cb85 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Box, @@ -14,54 +12,35 @@ import { Toolbar, Card, CardContent, - Alert + Alert, + Tabs, + Tab } from '@mui/material'; import LanguageSwitcher from '../components/LanguageSwitcher'; -import userService from '../services/user_service'; +import { useLoginPage } from '../hooks/login-hooks'; const Login = () => { const { t } = useTranslation(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [rememberEmail, setRememberEmail] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [emailError, setEmailError] = useState(false); - const [passwordError, setPasswordError] = useState(false); - const [loginError, setLoginError] = useState(''); - const navigate = useNavigate(); - - console.log(t, t('en'), t('login')); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - const hasEmail = !!email.trim(); - const hasPassword = !!password.trim(); - setEmailError(!hasEmail); - setPasswordError(!hasPassword); - setLoginError(''); + const { + // 表单状态 + tabValue, + setTabValue, + error, + loginForm, + registerForm, - if (!hasEmail || !hasPassword) return; - - setIsSubmitting(true); + // 表单操作 + handleTabChange, - try { - const response = await userService.login({ email, password }); - - // if (response.code === 0) { - // // 登录成功,跳转到主页 - // navigate('/'); - // } else { - // // 登录失败,显示错误信息 - // setLoginError(response.message || t('login.loginFailed')); - // } - } catch (error: any) { - // 处理网络错误或其他异常 - setLoginError(error.message || t('login.networkError')); - } finally { - setIsSubmitting(false); - } - }; + // 提交处理 + handleLogin, + handleRegister, + isSubmitting, + + // 验证规则 + loginValidation, + registerValidation + } = useLoginPage(); return ( @@ -96,67 +75,138 @@ const Login = () => { T-Systems Enterprise RAG Empowerment System - - {t('login.login')} -
{t('login.emailLabel')} & {t('login.passwordLabel')} -
+ + {/* 标签页 */} + + + + - - {loginError && ( - - {loginError} - - )} - - setEmail(e.target.value)} - error={emailError} - required - sx={{ mb: 2 }} - /> + {error && ( + + {error} + + )} - setPassword(e.target.value)} - error={passwordError} - required - sx={{ mb: 2 }} - /> - - - setRememberEmail(e.target.checked)} - id="rememberEmail" - /> - } - label={t('login.rememberMe')} + {/* 登录表单 */} + {tabValue === 0 && ( + + + {t('login.loginDescription')} + + + - - - + + + + + } + label={t('login.rememberMe')} + /> + + + + + )} + + {/* 注册表单 */} + {tabValue === 1 && ( + + + {t('login.registerDescription')} + + + + + + + + + + + )} - {t('login.signInTip')} {t('login.signUp')}. + {tabValue === 0 ? ( + <> + {t('login.signInTip')} setTabValue(1)}>{t('login.signUp')}. + + ) : ( + <> + {t('login.signUpTip')} setTabValue(0)}>{t('login.login')}. + + )} diff --git a/src/services/user_service.ts b/src/services/user_service.ts index f0f0aa5..2c52d4c 100644 --- a/src/services/user_service.ts +++ b/src/services/user_service.ts @@ -14,7 +14,7 @@ const userService = { }, // 用户注册 - register: (data: { email: string; password: string; username?: string }) => { + register: (data: { email: string; password: string; nickname?: string }) => { return post(api.register, data); }, diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts new file mode 100644 index 0000000..0b8ed9f --- /dev/null +++ b/src/utils/encryption.ts @@ -0,0 +1,36 @@ +import JSEncrypt from 'jsencrypt'; +import { Base64 } from 'js-base64'; + +// RSA公钥 +let RSA_PUBLIC_KEY = (import.meta.env.VITE_RSA_PUBLIC_KEY as string) || ''; + +/** + * RSA密码加密函数 + * @param password 明文密码 + * @returns 加密后的密码 + */ +export const rsaPsw = (password: string): string => { + try { + const encrypt = new JSEncrypt(); + const publicKey = RSA_PUBLIC_KEY; + console.log('publicKey', publicKey); + encrypt.setPublicKey(publicKey); + const encrypted = encrypt.encrypt(Base64.encode(password)); + return encrypted || password; + } catch (error) { + console.error('RSA encryption failed:', error); + return password; + } +}; + +/** + * 获取RSA公钥 + * @returns RSA公钥字符串 + */ +export const getRSAPublicKey = (): string => { + return RSA_PUBLIC_KEY; +}; + +export const setRSAPublicKey = (key: string) => { + RSA_PUBLIC_KEY = key; +}; \ No newline at end of file diff --git a/src/utils/request.ts b/src/utils/request.ts index ff431c0..5c57c42 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -120,26 +120,16 @@ request.interceptors.response.use( if (response.config.responseType === 'blob') { return response; } - + console.log('interceptors.response ------ response', response); const data: ResponseType = response.data; // 处理业务错误码 if (data?.code === 100) { snackbar.error(data?.message); } else if (data?.code === 401) { - // notification.error({ - // message: data?.message, - // description: data?.message, - // duration: 3, - // }); notification.error(data?.message); redirectToLogin(); } else if (data?.code !== 0) { - // notification.error({ - // message: `${i18n.t('message.hint')} : ${data?.code}`, - // description: data?.message, - // duration: 3, - // }); notification.error(`${i18n.t('message.hint')} : ${data?.code}`, data?.message); } @@ -148,19 +138,10 @@ request.interceptors.response.use( (error) => { // 处理网络错误 if (error.message === FAILED_TO_FETCH || !error.response) { - // notification.error({ - // description: i18n.t('message.networkAnomalyDescription'), - // message: i18n.t('message.networkAnomaly'), - // }); notification.error(i18n.t('message.networkAnomaly'), i18n.t('message.networkAnomalyDescription')); } else if (error.response) { const { status, statusText } = error.response; const errorText = RetcodeMessage[status as ResultCode] || statusText; - - // notification.error({ - // message: `${i18n.t('message.requestError')} ${status}`, - // description: errorText, - // }); notification.error(`${i18n.t('message.requestError')} ${status}`, errorText); }