feat: 两步验证增加自定义刷新时间 (#1441)

This commit is contained in:
ssongliu 2023-06-25 17:52:13 +08:00 committed by GitHub
parent 14f7435f82
commit efa3d06673
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 100 additions and 20 deletions

View file

@ -2,6 +2,8 @@ package v1
import ( import (
"errors" "errors"
"fmt"
"strconv"
"github.com/1Panel-dev/1Panel/backend/app/api/v1/helper" "github.com/1Panel-dev/1Panel/backend/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/backend/app/dto" "github.com/1Panel-dev/1Panel/backend/app/dto"
@ -260,11 +262,23 @@ func (b *BaseApi) CleanMonitor(c *gin.Context) {
// @Tags System Setting // @Tags System Setting
// @Summary Load mfa info // @Summary Load mfa info
// @Description 获取 mfa 信息 // @Description 获取 mfa 信息
// @Param interval path string true "request"
// @Success 200 {object} mfa.Otp // @Success 200 {object} mfa.Otp
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Router /settings/mfa [get] // @Router /settings/mfa/:interval [get]
func (b *BaseApi) GetMFA(c *gin.Context) { func (b *BaseApi) GetMFA(c *gin.Context) {
otp, err := mfa.GetOtp("admin") intervalStr, ok := c.Params.Get("interval")
if !ok {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, errors.New("error interval in path"))
return
}
interval, err := strconv.Atoi(intervalStr)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, fmt.Errorf("type conversion failed, err: %v", err))
return
}
otp, err := mfa.GetOtp("admin", interval)
if err != nil { if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return return
@ -288,12 +302,17 @@ func (b *BaseApi) MFABind(c *gin.Context) {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err) helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return return
} }
success := mfa.ValidCode(req.Code, req.Secret) success := mfa.ValidCode(req.Code, req.Interval, req.Secret)
if !success { if !success {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, errors.New("code is not valid")) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, errors.New("code is not valid"))
return return
} }
if err := settingService.Update("MFAInterval", req.Interval); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
if err := settingService.Update("MFAStatus", "enable"); err != nil { if err := settingService.Update("MFAStatus", "enable"); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return return

View file

@ -12,8 +12,9 @@ type UserLoginInfo struct {
} }
type MfaCredential struct { type MfaCredential struct {
Secret string `json:"secret"` Secret string `json:"secret"`
Code string `json:"code"` Code string `json:"code"`
Interval string `json:"interval"`
} }
type Login struct { type Login struct {

View file

@ -28,6 +28,7 @@ type SettingInfo struct {
ComplexityVerification string `json:"complexityVerification"` ComplexityVerification string `json:"complexityVerification"`
MFAStatus string `json:"mfaStatus"` MFAStatus string `json:"mfaStatus"`
MFASecret string `json:"mfaSecret"` MFASecret string `json:"mfaSecret"`
MFAInterval string `json:"mfaInterval"`
MonitorStatus string `json:"monitorStatus"` MonitorStatus string `json:"monitorStatus"`
MonitorInterval string `json:"monitorInterval"` MonitorInterval string `json:"monitorInterval"`

View file

@ -76,7 +76,11 @@ func (u *AuthService) MFALogin(c *gin.Context, info dto.MFALogin) (*dto.UserLogi
if err != nil { if err != nil {
return nil, err return nil, err
} }
success := mfa.ValidCode(info.Code, mfaSecret.Value) mfaInterval, err := settingRepo.Get(settingRepo.WithByKey("MFAInterval"))
if err != nil {
return nil, err
}
success := mfa.ValidCode(info.Code, mfaInterval.Value, mfaSecret.Value)
if !success { if !success {
return nil, constant.ErrAuth return nil, constant.ErrAuth
} }

View file

@ -32,6 +32,7 @@ func Init() {
migrations.UpdateCronjobWithSecond, migrations.UpdateCronjobWithSecond,
migrations.UpdateWebsite, migrations.UpdateWebsite,
migrations.AddBackupAccountDir, migrations.AddBackupAccountDir,
migrations.AddMfaInterval,
}) })
if err := m.Migrate(); err != nil { if err := m.Migrate(); err != nil {
global.LOG.Error(err) global.LOG.Error(err)

View file

@ -400,3 +400,13 @@ var AddBackupAccountDir = &gormigrate.Migration{
return nil return nil
}, },
} }
var AddMfaInterval = &gormigrate.Migration{
ID: "20230625-add-mfa-interval",
Migrate: func(tx *gorm.DB) error {
if err := tx.Create(&model.Setting{Key: "MFAInterval", Value: "30"}).Error; err != nil {
return err
}
return nil
},
}

View file

@ -29,7 +29,7 @@ func (s *SettingRouter) InitSettingRouter(Router *gin.RouterGroup) {
settingRouter.GET("/time/option", baseApi.LoadTimeZone) settingRouter.GET("/time/option", baseApi.LoadTimeZone)
settingRouter.POST("/time/sync", baseApi.SyncTime) settingRouter.POST("/time/sync", baseApi.SyncTime)
settingRouter.POST("/monitor/clean", baseApi.CleanMonitor) settingRouter.POST("/monitor/clean", baseApi.CleanMonitor)
settingRouter.GET("/mfa", baseApi.GetMFA) settingRouter.GET("/mfa/:interval", baseApi.GetMFA)
settingRouter.POST("/mfa/bind", baseApi.MFABind) settingRouter.POST("/mfa/bind", baseApi.MFABind)
settingRouter.POST("/snapshot", baseApi.CreateSnapshot) settingRouter.POST("/snapshot", baseApi.CreateSnapshot)

View file

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"github.com/xlzd/gotp" "github.com/xlzd/gotp"
) )
@ -17,10 +18,10 @@ type Otp struct {
QrImage string `json:"qrImage"` QrImage string `json:"qrImage"`
} }
func GetOtp(username string) (otp Otp, err error) { func GetOtp(username string, interval int) (otp Otp, err error) {
secret := gotp.RandomSecret(secretLength) secret := gotp.RandomSecret(secretLength)
otp.Secret = secret otp.Secret = secret
totp := gotp.NewDefaultTOTP(secret) totp := gotp.NewTOTP(secret, 6, interval, nil)
uri := totp.ProvisioningUri(username, "1Panel") uri := totp.ProvisioningUri(username, "1Panel")
subImg, err := qrcode.Encode(uri, qrcode.Medium, 256) subImg, err := qrcode.Encode(uri, qrcode.Medium, 256)
dist := make([]byte, 3000) dist := make([]byte, 3000)
@ -31,8 +32,13 @@ func GetOtp(username string) (otp Otp, err error) {
return return
} }
func ValidCode(code string, secret string) bool { func ValidCode(code, intervalStr, secret string) bool {
totp := gotp.NewDefaultTOTP(secret) interval, err := strconv.Atoi(intervalStr)
if err != nil {
global.LOG.Errorf("type conversion failed, err: %v", err)
return false
}
totp := gotp.NewTOTP(secret, 6, interval, nil)
now := time.Now().Unix() now := time.Now().Unix()
strInt64 := strconv.FormatInt(now, 10) strInt64 := strconv.FormatInt(now, 10)
id16, _ := strconv.Atoi(strInt64) id16, _ := strconv.Atoi(strInt64)

View file

@ -27,6 +27,7 @@ export namespace Setting {
complexityVerification: string; complexityVerification: string;
mfaStatus: string; mfaStatus: string;
mfaSecret: string; mfaSecret: string;
mfaInterval: string;
monitorStatus: string; monitorStatus: string;
monitorInterval: number; monitorInterval: number;
@ -71,6 +72,7 @@ export namespace Setting {
export interface MFABind { export interface MFABind {
secret: string; secret: string;
code: string; code: string;
interval: string;
} }
export interface SnapshotCreate { export interface SnapshotCreate {
from: string; from: string;

View file

@ -46,8 +46,8 @@ export const cleanMonitors = () => {
return http.post(`/settings/monitor/clean`, {}); return http.post(`/settings/monitor/clean`, {});
}; };
export const getMFA = () => { export const getMFA = (interval: number) => {
return http.get<Setting.MFAInfo>(`/settings/mfa`, {}); return http.get<Setting.MFAInfo>(`/settings/mfa/${interval}`, {});
}; };
export const loadDaemonJsonPath = () => { export const loadDaemonJsonPath = () => {

View file

@ -1050,6 +1050,8 @@ const message = {
allowIPEgs: allowIPEgs:
'If multiple ip authorizations exist, newlines need to be displayed. For example, \n172.16.10.111 \n172.16.10.112', 'If multiple ip authorizations exist, newlines need to be displayed. For example, \n172.16.10.111 \n172.16.10.112',
mfa: 'MFA', mfa: 'MFA',
mfaInterval: 'Refresh interval (s)',
mfaIntervalHelper: 'Please rescan or manually add key information after modifying the refresh time.',
mfaAlert: mfaAlert:
'MFA password is generated based on the current time. Please ensure that the server time is synchronized.', 'MFA password is generated based on the current time. Please ensure that the server time is synchronized.',
mfaHelper: 'After this function is enabled, the mobile application verification code will be verified', mfaHelper: 'After this function is enabled, the mobile application verification code will be verified',

View file

@ -1058,6 +1058,8 @@ const message = {
mfaHelper2: '使用手机应用扫描以下二维码获取 6 位验证码', mfaHelper2: '使用手机应用扫描以下二维码获取 6 位验证码',
mfaHelper3: '输入手机应用上的 6 位数字', mfaHelper3: '输入手机应用上的 6 位数字',
mfaCode: '验证码', mfaCode: '验证码',
mfaInterval: '刷新时间',
mfaIntervalHelper: '修改刷新时间后请重新扫描或手动添加密钥信息',
sslChangeHelper: 'https 设置修改需要重启服务是否继续', sslChangeHelper: 'https 设置修改需要重启服务是否继续',
sslDisable: '禁用', sslDisable: '禁用',
sslDisableHelper: '禁用 https 服务需要重启面板才能生效是否继续', sslDisableHelper: '禁用 https 服务需要重启面板才能生效是否继续',

View file

@ -194,6 +194,7 @@ const form = reactive({
expirationTime: '', expirationTime: '',
complexityVerification: 'disable', complexityVerification: 'disable',
mfaStatus: 'disable', mfaStatus: 'disable',
mfaInterval: 30,
allowIPs: '', allowIPs: '',
bindDomain: '', bindDomain: '',
}); });
@ -213,6 +214,7 @@ const search = async () => {
form.expirationTime = res.data.expirationTime; form.expirationTime = res.data.expirationTime;
form.complexityVerification = res.data.complexityVerification; form.complexityVerification = res.data.complexityVerification;
form.mfaStatus = res.data.mfaStatus; form.mfaStatus = res.data.mfaStatus;
form.mfaInterval = Number(res.data.mfaInterval);
form.allowIPs = res.data.allowIPs.replaceAll(',', '\n'); form.allowIPs = res.data.allowIPs.replaceAll(',', '\n');
form.bindDomain = res.data.bindDomain; form.bindDomain = res.data.bindDomain;
}; };
@ -236,7 +238,7 @@ const onSaveComplexity = async () => {
const handleMFA = async () => { const handleMFA = async () => {
if (form.mfaStatus === 'enable') { if (form.mfaStatus === 'enable') {
mfaRef.value.acceptParams(); mfaRef.value.acceptParams({ interval: form.mfaInterval });
return; return;
} }
loading.value = true; loading.value = true;

View file

@ -17,7 +17,14 @@
</span> </span>
</template> </template>
</el-alert> </el-alert>
<el-form :model="form" ref="formRef" @submit.prevent v-loading="loading" label-position="top"> <el-form
:model="form"
ref="formRef"
@submit.prevent
v-loading="loading"
label-position="top"
:rules="rules"
>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-col :span="22"> <el-col :span="22">
<el-form-item :label="$t('setting.mfaHelper1')"> <el-form-item :label="$t('setting.mfaHelper1')">
@ -45,7 +52,15 @@
</div> </div>
</span> </span>
</el-form-item> </el-form-item>
<el-form-item :label="$t('setting.mfaCode')" prop="code" :rules="Rules.requiredInput"> <el-form-item :label="$t('setting.mfaInterval')" prop="interval">
<el-input v-model.number="form.interval">
<template #append>
<el-button @click="loadMfaCode">{{ $t('commons.button.save') }}</el-button>
</template>
</el-input>
<span class="input-help">{{ $t('setting.mfaIntervalHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.mfaCode')" prop="code">
<el-input v-model="form.code"></el-input> <el-input v-model="form.code"></el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -65,7 +80,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { bindMFA, getMFA } from '@/api/modules/setting'; import { bindMFA, getMFA } from '@/api/modules/setting';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { Rules } from '@/global/form-rules'; import { Rules, checkNumberRange } from '@/global/form-rules';
import i18n from '@/lang'; import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
import { FormInstance } from 'element-plus'; import { FormInstance } from 'element-plus';
@ -81,10 +96,20 @@ const formRef = ref();
const form = reactive({ const form = reactive({
code: '', code: '',
secret: '', secret: '',
interval: 30,
}); });
const rules = reactive({
code: [Rules.requiredInput],
mfaInterval: [Rules.number, checkNumberRange(15, 300)],
});
interface DialogProps {
interval: number;
}
const emit = defineEmits<{ (e: 'search'): void }>(); const emit = defineEmits<{ (e: 'search'): void }>();
const acceptParams = (): void => { const acceptParams = (params: DialogProps): void => {
form.interval = params.interval;
loadMfaCode(); loadMfaCode();
drawerVisiable.value = true; drawerVisiable.value = true;
}; };
@ -99,7 +124,7 @@ const onCopy = async () => {
}; };
const loadMfaCode = async () => { const loadMfaCode = async () => {
const res = await getMFA(); const res = await getMFA(form.interval);
form.secret = res.data.secret; form.secret = res.data.secret;
qrImage.value = res.data.qrImage; qrImage.value = res.data.qrImage;
}; };
@ -108,8 +133,13 @@ const onBind = async (formEl: FormInstance | undefined) => {
if (!formEl) return; if (!formEl) return;
formEl.validate(async (valid) => { formEl.validate(async (valid) => {
if (!valid) return; if (!valid) return;
let param = {
code: form.code,
secret: form.secret,
interval: form.interval + '',
};
loading.value = true; loading.value = true;
await bindMFA(form) await bindMFA(param)
.then(() => { .then(() => {
loading.value = false; loading.value = false;
drawerVisiable.value = false; drawerVisiable.value = false;