diff --git a/src/assets/svg/lock.svg b/src/assets/svg/lock.svg new file mode 100644 index 0000000..54890e2 --- /dev/null +++ b/src/assets/svg/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/safe.svg b/src/assets/svg/safe.svg new file mode 100644 index 0000000..5f19ab9 --- /dev/null +++ b/src/assets/svg/safe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/unlock.svg b/src/assets/svg/unlock.svg new file mode 100644 index 0000000..6f740bb --- /dev/null +++ b/src/assets/svg/unlock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/constants/common.constants.ts b/src/constants/common.constants.ts index 29052c6..6a4a6fe 100644 --- a/src/constants/common.constants.ts +++ b/src/constants/common.constants.ts @@ -61,6 +61,10 @@ export const PERMISSION_ACCOUNT_NEED_INIT = 20064 export const PERMISSION_USER_NOT_FOUND = 20065 export const PERMISSION_RETRIEVE_CODE_ERROR_OR_EXPIRED = 20066 export const PERMISSION_ACCOUNT_NEED_RESET_PASSWORD = 20067 +export const PERMISSION_NEED_TWO_FACTOR = 20068 +export const PERMISSION_ALREADY_HAS_TWO_FACTOR = 20069 +export const PERMISSION_NO_TWO_FACTOR_FOUND = 20070 +export const PERMISSION_TWO_FACTOR_VERIFICATION_CODE_ERROR = 20071 export const DATABASE_SELECT_SUCCESS = 30000 export const DATABASE_SELECT_FAILED = 30005 diff --git a/src/constants/urls.constants.ts b/src/constants/urls.constants.ts index ae9b637..615dab8 100644 --- a/src/constants/urls.constants.ts +++ b/src/constants/urls.constants.ts @@ -6,6 +6,7 @@ export const URL_RETRIEVE = '/retrieve' export const URL_LOGIN = '/login' export const URL_TOKEN = '/token' export const URL_LOGOUT = '/logout' +export const URL_TWO_FACTOR = '/two-factor' export const URL_SYS_LOG = '/system/log' export const URL_SYS_USER_INFO = '/system/user/info' export const URL_SYS_USER = '/system/user' @@ -18,6 +19,7 @@ export const URL_SYS_SETTINGS = '/system/settings' export const URL_SYS_SETTINGS_BASE = `${URL_SYS_SETTINGS}/base` export const URL_SYS_SETTINGS_MAIL = `${URL_SYS_SETTINGS}/mail` export const URL_SYS_SETTINGS_SENSITIVE = `${URL_SYS_SETTINGS}/sensitive` +export const URL_SYS_SETTINGS_TWO_FACTOR = `${URL_SYS_SETTINGS}/two-factor` export const URL_SYS_STATISTICS = '/system/statistics' export const URL_SYS_STATISTICS_SOFTWARE = `${URL_SYS_STATISTICS}/software` export const URL_SYS_STATISTICS_HARDWARE = `${URL_SYS_STATISTICS}/hardware` diff --git a/src/global.d.ts b/src/global.d.ts index 8562706..be92e36 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -92,6 +92,7 @@ interface LoginParam { account: string password: string captchaCode: string + twoFactorCode?: string } interface UserChangePasswordParam { @@ -99,9 +100,18 @@ interface UserChangePasswordParam { newPassword: string } +interface TwoFactorValidateParam { + code: string +} + +interface TwoFactorRemoveParam { + code: string +} + interface UserWithInfoVo { id: string username: string + twoFactor: boolean verified: boolean locking: boolean expiration: string @@ -119,6 +129,7 @@ interface UserWithInfoVo { interface UserWithPowerInfoVo { id: string username: string + twoFactor: boolean verified: boolean locking: boolean expiration: string @@ -140,6 +151,7 @@ interface UserWithPowerInfoVo { interface UserWithRoleInfoVo { id: string username: string + twoFactor: boolean verify: string locking: boolean expiration: string @@ -164,6 +176,10 @@ interface UserInfoVo { email: string } +interface TwoFactorVo { + qrCodeSVGBase64: string +} + interface ModuleVo { id: number name: string @@ -402,6 +418,16 @@ interface SensitiveWordUpdateParam { ids: string[] } +interface TwoFactorSettingsVo { + issuer: string + secretKeyLength: number +} + +interface TwoFactorSettingsParam { + issuer: string + secretKeyLength: number +} + interface SoftwareInfoVo { os: string bitness: number diff --git a/src/pages/Sign/SignIn.tsx b/src/pages/Sign/SignIn.tsx index 11d86f0..5e7dfa3 100644 --- a/src/pages/Sign/SignIn.tsx +++ b/src/pages/Sign/SignIn.tsx @@ -4,6 +4,8 @@ import { H_CAPTCHA_SITE_KEY, PERMISSION_LOGIN_SUCCESS, PERMISSION_LOGIN_USERNAME_PASSWORD_ERROR, + PERMISSION_NEED_TWO_FACTOR, + PERMISSION_TWO_FACTOR_VERIFICATION_CODE_ERROR, PERMISSION_USER_DISABLE, PERMISSION_USERNAME_NOT_FOUND, SYSTEM_INVALID_CAPTCHA_CODE @@ -16,6 +18,7 @@ import FitCenter from '@/components/common/FitCenter' import FlexBox from '@/components/common/FlexBox' const SignIn = () => { + const [modal, contextHolder] = AntdModal.useModal() const { refreshRouter } = useContext(AppContext) const navigate = useNavigate() const [searchParams] = useSearchParams() @@ -29,6 +32,7 @@ const SignIn = () => { }, [location.pathname] ) + const [twoFactorForm] = AntdForm.useForm<{ twoFactorCode: string }>() const [isSigningIn, setIsSigningIn] = useState(false) const [captchaCode, setCaptchaCode] = useState('') @@ -55,7 +59,8 @@ const SignIn = () => { void r_auth_login({ account: loginParam.account, password: loginParam.password, - captchaCode + captchaCode, + twoFactorCode: loginParam.twoFactorCode }) .then((res) => { const response = res.data @@ -96,6 +101,56 @@ const SignIn = () => { }) }, 1500) break + case PERMISSION_NEED_TWO_FACTOR: + twoFactorForm.resetFields() + void modal.confirm({ + title: '双因素验证', + getContainer: false, + centered: true, + content: ( + <> + + + { + input?.focus() + }} + /> + + + + ), + onOk: () => + twoFactorForm.validateFields().then( + () => { + return new Promise((resolve) => { + handleOnFinish({ + ...loginParam, + twoFactorCode: twoFactorForm.getFieldValue( + 'twoFactorCode' + ) as string + }) + resolve() + }) + }, + () => { + return new Promise((_, reject) => { + reject('输入有误') + }) + } + ), + onCancel: () => { + setIsSigningIn(false) + } + }) + break case PERMISSION_USERNAME_NOT_FOUND: case PERMISSION_LOGIN_USERNAME_PASSWORD_ERROR: void message.error( @@ -105,6 +160,10 @@ const SignIn = () => { ) setIsSigningIn(false) break + case PERMISSION_TWO_FACTOR_VERIFICATION_CODE_ERROR: + void message.error('双因素验证码错误') + setIsSigningIn(false) + break case PERMISSION_USER_DISABLE: void message.error( <> @@ -170,7 +229,13 @@ const SignIn = () => { /> - 记住密码 + { + navigate('/') + }} + > + 返回首页 + { navigate(`/forget${location.search}`, { replace: true }) @@ -203,6 +268,7 @@ const SignIn = () => { + {contextHolder} ) } diff --git a/src/pages/System/Settings/TwoFactor.tsx b/src/pages/System/Settings/TwoFactor.tsx new file mode 100644 index 0000000..a94e8fc --- /dev/null +++ b/src/pages/System/Settings/TwoFactor.tsx @@ -0,0 +1,73 @@ +import { hasPermission } from '@/util/auth' +import { r_sys_settings_two_factor_get, r_sys_settings_two_factor_update } from '@/services/system' +import { SettingsCard } from '@/pages/System/Settings' + +const TwoFactor = () => { + const [twoFactorForm] = AntdForm.useForm() + const twoFactorFormValues = AntdForm.useWatch([], twoFactorForm) + const [loading, setLoading] = useState(false) + + const handleOnReset = () => { + getTwoFactorSettings() + } + + const handleOnSave = () => { + void r_sys_settings_two_factor_update(twoFactorFormValues).then((res) => { + const response = res.data + if (response.success) { + void message.success('保存设置成功') + getTwoFactorSettings() + } else { + void message.error('保存设置失败,请稍后重试') + } + }) + } + + const getTwoFactorSettings = () => { + if (loading) { + return + } + setLoading(true) + + void r_sys_settings_two_factor_get().then((res) => { + const response = res.data + if (response.success) { + const data = response.data + data && twoFactorForm.setFieldsValue(data) + setLoading(false) + } + }) + } + + useEffect(() => { + getTwoFactorSettings() + }, []) + + return ( + <> + + + + + + + + + + + + ) +} + +export default TwoFactor diff --git a/src/pages/System/Settings/index.tsx b/src/pages/System/Settings/index.tsx index ac2d4c6..94cba62 100644 --- a/src/pages/System/Settings/index.tsx +++ b/src/pages/System/Settings/index.tsx @@ -10,6 +10,7 @@ import Permission from '@/components/common/Permission' import Base from '@/pages/System/Settings/Base' import Mail from '@/pages/System/Settings/Mail' import SensitiveWord from '@/pages/System/Settings/SensitiveWord' +import TwoFactor from '@/pages/System/Settings/TwoFactor.tsx' interface SettingsCardProps extends PropsWithChildren { icon: IconComponent @@ -68,6 +69,9 @@ const Settings = () => { + + + diff --git a/src/pages/User/index.tsx b/src/pages/User/index.tsx index 7af2d92..61ae9c6 100644 --- a/src/pages/User/index.tsx +++ b/src/pages/User/index.tsx @@ -11,6 +11,11 @@ import { import { utcToLocalTime } from '@/util/datetime' import { getUserInfo, removeToken } from '@/util/auth' import { r_sys_user_info_change_password, r_sys_user_info_update } from '@/services/system' +import { + r_auth_two_factor_create, + r_auth_two_factor_remove, + r_auth_two_factor_validate +} from '@/services/auth' import { r_api_avatar_random_base64 } from '@/services/api/avatar' import FitFullscreen from '@/components/common/FitFullscreen' import Card from '@/components/common/Card' @@ -25,6 +30,7 @@ const User = () => { const [modal, contextHolder] = AntdModal.useModal() const [form] = AntdForm.useForm() const formValues = AntdForm.useWatch([], form) + const [twoFactorForm] = AntdForm.useForm<{ twoFactorCode: string }>() const [isLoading, setIsLoading] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmittable, setIsSubmittable] = useState(false) @@ -93,6 +99,8 @@ const User = () => { 修改密码 ), + getContainer: false, + centered: true, maskClosable: true, content: ( { labelAlign={'right'} rules={[{ required: true, message: '请输入原密码' }]} > - + { + input?.focus() + }} + /> { }) } + const handleOnChangeTwoFactor = (enable: boolean) => { + return () => { + twoFactorForm.resetFields() + if (enable) { + void modal.confirm({ + title: '双因素', + centered: true, + maskClosable: true, + content: '确定解除双因素?', + onOk: () => { + void modal.confirm({ + title: '解除双因素', + getContainer: false, + centered: true, + maskClosable: true, + content: ( + <> + + + { + input?.focus() + }} + /> + + + + ), + onOk: () => + twoFactorForm.validateFields().then( + () => { + return new Promise((resolve) => { + void r_auth_two_factor_remove({ + code: twoFactorForm.getFieldValue( + 'twoFactorCode' + ) as string + }) + .then((res) => { + const response = res.data + if (response.success) { + void message.success('解绑成功') + getProfile() + } else { + void message.error('解绑失败,请稍后重试') + } + }) + .finally(() => { + resolve() + }) + }) + }, + () => { + return new Promise((_, reject) => { + reject('输入有误') + }) + } + ) + }) + } + }) + } else { + if (isLoading) { + return + } + setIsLoading(true) + void message.loading({ content: '加载中', key: 'LOADING', duration: 0 }) + void r_auth_two_factor_create() + .then((res) => { + message.destroy('LOADING') + const response = res.data + if (response.success) { + void modal.confirm({ + title: '绑定双因素', + getContainer: false, + centered: true, + maskClosable: true, + content: ( + <> + + + + { + input?.focus() + }} + /> + + + + ), + onOk: () => + twoFactorForm.validateFields().then( + () => { + return new Promise((resolve) => { + void r_auth_two_factor_validate({ + code: twoFactorForm.getFieldValue( + 'twoFactorCode' + ) as string + }) + .then((res) => { + const response = res.data + if (response.success) { + void message.success('绑定成功') + getProfile() + } else { + void message.error( + '绑定失败,请稍后重试' + ) + } + }) + .finally(() => { + resolve() + }) + }) + }, + () => { + return new Promise((_, reject) => { + reject('输入有误') + }) + } + ) + }) + } else { + void message.error('获取双因素绑定二维码失败,请稍后重试') + } + }) + .catch(() => { + message.destroy('LOADING') + }) + .finally(() => { + setIsLoading(false) + }) + } + } + } + const getProfile = () => { if (isLoading) { return @@ -382,6 +549,40 @@ const User = () => { + +
双因素
+
+ + + + + + +
+
diff --git a/src/services/auth.tsx b/src/services/auth.tsx index c470526..f3f080b 100644 --- a/src/services/auth.tsx +++ b/src/services/auth.tsx @@ -5,6 +5,7 @@ import { URL_REGISTER, URL_RESEND, URL_RETRIEVE, + URL_TWO_FACTOR, URL_VERIFY } from '@/constants/urls.constants' import request from '@/services' @@ -21,4 +22,12 @@ export const r_auth_retrieve = (param: RetrieveParam) => request.post(URL_RETRIE export const r_auth_login = (param: LoginParam) => request.post(URL_LOGIN, param) +export const r_auth_two_factor_create = () => request.get(URL_TWO_FACTOR) + +export const r_auth_two_factor_validate = (param: TwoFactorValidateParam) => + request.post(URL_TWO_FACTOR, param) + +export const r_auth_two_factor_remove = (param: TwoFactorRemoveParam) => + request.delete(URL_TWO_FACTOR, param) + export const r_auth_logout = () => request.post(URL_LOGOUT) diff --git a/src/services/system.tsx b/src/services/system.tsx index 0047416..c64bef0 100644 --- a/src/services/system.tsx +++ b/src/services/system.tsx @@ -20,7 +20,8 @@ import { URL_SYS_TOOL_CATEGORY, URL_SYS_TOOL_BASE, URL_SYS_TOOL_TEMPLATE, - URL_SYS_TOOL + URL_SYS_TOOL, + URL_SYS_SETTINGS_TWO_FACTOR } from '@/constants/urls.constants' import request from '@/services/index' @@ -111,6 +112,12 @@ export const r_sys_settings_sensitive_update = (param: SensitiveWordUpdateParam) export const r_sys_settings_sensitive_delete = (id: string) => request.delete(`${URL_SYS_SETTINGS_SENSITIVE}/${id}`) +export const r_sys_settings_two_factor_get = () => + request.get(URL_SYS_SETTINGS_TWO_FACTOR) + +export const r_sys_settings_two_factor_update = (param: TwoFactorSettingsParam) => + request.put(URL_SYS_SETTINGS_TWO_FACTOR, param) + export const r_sys_statistics_software = () => request.get(URL_SYS_STATISTICS_SOFTWARE)