Add two-factor

This commit is contained in:
2024-03-01 15:33:30 +08:00
parent e563c2e8be
commit 935a1a223a
12 changed files with 399 additions and 4 deletions

1
src/assets/svg/lock.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 725.333333c46.933333 0 85.333333-38.4 85.333333-85.333333s-38.4-85.333333-85.333333-85.333333-85.333333 38.4-85.333333 85.333333 38.4 85.333333 85.333333 85.333333z m256-384h-42.666667v-85.333333c0-117.76-95.573333-213.333333-213.333333-213.333333S298.666667 138.24 298.666667 256v85.333333h-42.666667c-46.933333 0-85.333333 38.4-85.333333 85.333334v426.666666c0 46.933333 38.4 85.333333 85.333333 85.333334h512c46.933333 0 85.333333-38.4 85.333333-85.333334V426.666667c0-46.933333-38.4-85.333333-85.333333-85.333334z m-388.266667-85.333333c0-72.96 59.306667-132.266667 132.266667-132.266667s132.266667 59.306667 132.266667 132.266667v85.333333H379.733333v-85.333333zM768 853.333333H256V426.666667h512v426.666666z" /></svg>

After

Width:  |  Height:  |  Size: 802 B

1
src/assets/svg/safe.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 78.869333l21.162667 12.096c69.717333 39.829333 150.677333 65.344 215.466666 80.874667 32.149333 7.722667 59.776 12.864 79.274667 16.064a824.981333 824.981333 0 0 0 28.437333 4.16l1.386667 0.170667 0.32 0.021333L896 196.48v361.728l-0.576 3.477333L853.333333 554.666667c42.090667 7.018667 42.069333 7.04 42.069334 7.061333v0.064l-0.021334 0.106667-0.064 0.32-0.170666 0.917333-0.554667 2.922667c-0.490667 2.410667-1.216 5.76-2.218667 9.898666-2.026667 8.277333-5.226667 19.861333-10.005333 33.856a495.445333 495.445333 0 0 1-51.797333 106.666667c-52.736 82.112-146.069333 174.869333-306.773334 221.098667l-11.797333 3.413333-11.797333-3.413333c-160.704-46.229333-254.037333-138.986667-306.773334-221.12a495.573333 495.573333 0 0 1-51.797333-106.645334 394.709333 394.709333 0 0 1-10.005333-33.856 262.250667 262.250667 0 0 1-2.773334-12.8l-0.192-0.938666-0.042666-0.32-0.021334-0.106667v-0.064c0-0.021333 0-0.042667 42.069334-7.061333l-42.090667 7.018666L128 558.208V196.48l37.952-4.224 0.32-0.021333 1.386667-0.170667 5.824-0.768c5.184-0.704 12.864-1.813333 22.613333-3.413333 19.498667-3.2 47.146667-8.32 79.274667-16.042667 64.768-15.530667 145.749333-41.045333 215.466666-80.874667L512 78.869333z m-298.666667 192.661334v278.976a310.4 310.4 0 0 0 9.045334 31.701333c7.765333 22.72 21.056 54.186667 42.858666 88.149333 42.026667 65.408 116.16 141.013333 246.762667 181.696 130.602667-40.661333 204.757333-116.288 246.762667-181.696a410.304 410.304 0 0 0 42.88-88.149333A309.76 309.76 0 0 0 810.666667 550.506667V271.530667c-20.906667-3.477333-49.28-8.853333-81.962667-16.704C666.154667 239.786667 586.026667 215.338667 512 176.618667c-74.026667 38.72-154.176 63.189333-216.704 78.208-32.704 7.850667-61.077333 13.226667-81.962667 16.704z m517.098667 146.154666L486.933333 661.184 328.746667 503.04l60.352-60.330667 97.834666 97.813334 183.146667-183.146667 60.352 60.330667z" /></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 725.333333c47.146667 0 85.333333-38.186667 85.333333-85.333333s-38.186667-85.333333-85.333333-85.333333-85.333333 38.186667-85.333333 85.333333 38.186667 85.333333 85.333333 85.333333z m256-384h-42.666667v-85.333333c0-117.76-95.573333-213.333333-213.333333-213.333333S298.666667 138.24 298.666667 256h81.066666c0-72.96 59.306667-132.266667 132.266667-132.266667 72.96 0 132.266667 59.306667 132.266667 132.266667v85.333333H256c-47.146667 0-85.333333 38.186667-85.333333 85.333334v426.666666c0 47.146667 38.186667 85.333333 85.333333 85.333334h512c47.146667 0 85.333333-38.186667 85.333333-85.333334V426.666667c0-47.146667-38.186667-85.333333-85.333333-85.333334z m0 512H256V426.666667h512v426.666666z" /></svg>

After

Width:  |  Height:  |  Size: 788 B

View File

@@ -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

View File

@@ -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`

26
src/global.d.ts vendored
View File

@@ -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

View File

@@ -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: (
<>
<AntdForm form={twoFactorForm}>
<AntdForm.Item
name={'twoFactorCode'}
label={'验证码'}
style={{ marginTop: 10 }}
rules={[{ required: true, len: 6 }]}
>
<AntdInput
showCount
maxLength={6}
ref={(input) => {
input?.focus()
}}
/>
</AntdForm.Item>
</AntdForm>
</>
),
onOk: () =>
twoFactorForm.validateFields().then(
() => {
return new Promise<void>((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 = () => {
/>
</AntdForm.Item>
<FlexBox direction={'horizontal'} className={'addition'}>
<AntdCheckbox disabled={isSigningIn}></AntdCheckbox>
<a
onClick={() => {
navigate('/')
}}
>
</a>
<a
onClick={() => {
navigate(`/forget${location.search}`, { replace: true })
@@ -203,6 +268,7 @@ const SignIn = () => {
</AntdForm>
</FlexBox>
</FitCenter>
{contextHolder}
</div>
)
}

View File

@@ -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<TwoFactorSettingsParam>()
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 (
<>
<SettingsCard
icon={IconOxygenSafe}
title={'双因素'}
loading={loading}
onReset={handleOnReset}
onSave={handleOnSave}
modifyOperationCode={['system:settings:modify:two-factor']}
>
<AntdForm
form={twoFactorForm}
labelCol={{ flex: '7em' }}
disabled={!hasPermission('system:settings:modify:two-factor')}
>
<AntdForm.Item label={'提供者'} name={'issuer'}>
<AntdInput />
</AntdForm.Item>
<AntdForm.Item label={'密钥长度'} name={'secretKeyLength'}>
<AntdInputNumber min={3} max={64} style={{ width: '100%' }} />
</AntdForm.Item>
</AntdForm>
</SettingsCard>
</>
)
}
export default TwoFactor

View File

@@ -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 = () => {
<Permission operationCode={['system:settings:query:mail']}>
<Mail />
</Permission>
<Permission operationCode={['system:settings:query:two-factor']}>
<TwoFactor />
</Permission>
</FlexBox>
</FlexBox>
</HideScrollbar>

View File

@@ -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<UserInfoUpdateParam>()
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: (
<AntdForm
@@ -107,7 +115,12 @@ const User = () => {
labelAlign={'right'}
rules={[{ required: true, message: '请输入原密码' }]}
>
<AntdInput.Password placeholder={'请输入原密码'} />
<AntdInput.Password
placeholder={'请输入原密码'}
ref={(input) => {
input?.focus()
}}
/>
</AntdForm.Item>
<AntdForm.Item
name={'newPassword'}
@@ -196,6 +209,160 @@ const User = () => {
})
}
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: (
<>
<AntdForm form={twoFactorForm}>
<AntdForm.Item
name={'twoFactorCode'}
label={'验证码'}
style={{ marginTop: 10 }}
rules={[{ required: true, len: 6 }]}
>
<AntdInput
showCount
maxLength={6}
ref={(input) => {
input?.focus()
}}
/>
</AntdForm.Item>
</AntdForm>
</>
),
onOk: () =>
twoFactorForm.validateFields().then(
() => {
return new Promise<void>((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: (
<>
<AntdImage
src={`data:image/svg+xml;base64,${response.data?.qrCodeSVGBase64}`}
alt={'Two-factor'}
preview={false}
/>
<AntdForm form={twoFactorForm}>
<AntdForm.Item
name={'twoFactorCode'}
label={'验证码'}
style={{ marginTop: 10, marginRight: 30 }}
rules={[{ required: true, len: 6 }]}
>
<AntdInput
showCount
maxLength={6}
ref={(input) => {
input?.focus()
}}
/>
</AntdForm.Item>
</AntdForm>
</>
),
onOk: () =>
twoFactorForm.validateFields().then(
() => {
return new Promise<void>((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 = () => {
</AntdSpace.Compact>
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdSpace.Compact>
<AntdInput
disabled
style={{
color: userWithPowerInfoVo?.twoFactor
? COLOR_PRODUCTION
: undefined
}}
value={
userWithPowerInfoVo?.twoFactor ? '已设置' : '未设置'
}
/>
<AntdButton
type={'primary'}
title={userWithPowerInfoVo?.twoFactor ? '解绑' : '绑定'}
disabled={isLoading}
onClick={handleOnChangeTwoFactor(
userWithPowerInfoVo?.twoFactor ?? false
)}
>
<Icon
component={
userWithPowerInfoVo?.twoFactor
? IconOxygenUnlock
: IconOxygenLock
}
/>
</AntdButton>
</AntdSpace.Compact>
</div>
</FlexBox>
</FlexBox>
</Card>
</HideScrollbar>

View File

@@ -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<TokenVo>(URL_LOGIN, param)
export const r_auth_two_factor_create = () => request.get<TwoFactorVo>(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)

View File

@@ -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<TwoFactorSettingsVo>(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<SoftwareInfoVo>(URL_SYS_STATISTICS_SOFTWARE)