diff --git a/frontend/src/api/queries/useAllSettings.ts b/frontend/src/api/queries/useAllSettings.ts index dafee8aab..89f03bccd 100644 --- a/frontend/src/api/queries/useAllSettings.ts +++ b/frontend/src/api/queries/useAllSettings.ts @@ -7,6 +7,8 @@ import { AllSetting } from '@/models/setting'; import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting'; import { keys } from '@/api/queryKeys'; +type SettingSavePayload = Partial & Record; + async function fetchAllSetting(): Promise { const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings'); @@ -42,19 +44,21 @@ export function useAllSettings() { }, []); const saveMut = useMutation({ - mutationFn: async (next: AllSetting): Promise> => { - const body = AllSettingSchema.partial().safeParse(next); + mutationFn: async (next: SettingSavePayload): Promise> => { + const payload = { ...next }; + const body = AllSettingSchema.partial().safeParse(payload); if (!body.success) { console.warn('[zod] setting/update body failed validation', body.error.issues); } - return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next); + return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload); }, onSuccess: (msg) => { if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() }); }, }); - const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]); + const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]); + const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]); const saveDisabled = useMemo(() => server.equals(draft), [server, draft]); return { @@ -65,5 +69,6 @@ export function useAllSettings() { setSpinning: setExtraSpinning, saveDisabled, saveAll, + savePayload, }; } diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index 5a042f6ad..631ececfa 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/frontend/src/pages/settings/SecurityTab.tsx @@ -37,6 +37,7 @@ interface ApiTokenRow { interface SecurityTabProps { allSetting: AllSetting; updateSetting: (patch: Partial) => void; + saveSetting: (payload: Partial & Record) => Promise; } const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000; @@ -65,7 +66,7 @@ const TFA_INITIAL: TfaState = { onConfirm: () => {}, }; -export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) { +export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); @@ -99,10 +100,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr setUser((prev) => ({ ...prev, [key]: value })); } - const sendUpdateUser = useCallback(async () => { + const sendUpdateUser = useCallback(async (twoFactorCode = '') => { setUpdating(true); try { - const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg; + const msg = await HttpUtil.post('/panel/api/setting/updateUser', { ...user, twoFactorCode }) as ApiMsg; if (msg?.success) { await HttpUtil.post('/logout'); const basePath = window.X_UI_BASE_PATH || '/'; @@ -118,9 +119,11 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr openTfa({ title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'), description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'), - token: allSetting.twoFactorToken, + token: '', type: 'confirm', - onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); }, + onConfirm: (ok: boolean, code?: string) => { + if (ok) sendUpdateUser(code || ''); + }, }); } else { sendUpdateUser(); @@ -224,12 +227,21 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr openTfa({ title: t('pages.settings.security.twoFactorModalDeleteTitle'), description: t('pages.settings.security.twoFactorModalRemoveStep'), - token: allSetting.twoFactorToken, + token: '', type: 'confirm', - onConfirm: (ok: boolean) => { + onConfirm: async (ok: boolean, code?: string) => { if (!ok) return; - messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess')); - updateSetting({ twoFactorEnable: false, twoFactorToken: '' }); + const next = { + ...allSetting, + twoFactorEnable: false, + twoFactorToken: '', + twoFactorCode: code || '', + }; + const msg = await saveSetting(next) as ApiMsg; + if (msg?.success) { + messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess')); + updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false }); + } }, }); } diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 4e70cf682..fa5a58bb7 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -76,6 +76,7 @@ export default function SettingsPage() { setSpinning, saveDisabled, saveAll, + savePayload, } = useAllSettings(); const [entryHost, setEntryHost] = useState(''); @@ -196,7 +197,7 @@ export default function SettingsPage() { const categoryBody = useMemo(() => { switch (activeSlug) { - case 'security': return ; + case 'security': return ; case 'telegram': return ; case 'email': return ; case 'subscription': return ; diff --git a/frontend/src/test/api-token-date.test.tsx b/frontend/src/test/api-token-date.test.tsx index 31b75befa..3130435cf 100644 --- a/frontend/src/test/api-token-date.test.tsx +++ b/frontend/src/test/api-token-date.test.tsx @@ -26,7 +26,7 @@ describe('API token creation date', () => { ], }); - render(); + render(); fireEvent.click(screen.getByRole('tab', { name: /API Token/ })); expect(await screen.findByText('seconds-token')).toBeTruthy(); diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index ecc976301..4705939ae 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -19,10 +19,16 @@ import ( // updateUserForm represents the form for updating user credentials. type updateUserForm struct { - OldUsername string `json:"oldUsername" form:"oldUsername"` - OldPassword string `json:"oldPassword" form:"oldPassword"` - NewUsername string `json:"newUsername" form:"newUsername"` - NewPassword string `json:"newPassword" form:"newPassword"` + OldUsername string `json:"oldUsername" form:"oldUsername"` + OldPassword string `json:"oldPassword" form:"oldPassword"` + NewUsername string `json:"newUsername" form:"newUsername"` + NewPassword string `json:"newPassword" form:"newPassword"` + TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` +} + +type updateSettingForm struct { + entity.AllSetting + TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` } // SettingController handles settings and user management operations. @@ -82,23 +88,30 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) { // updateSetting updates all settings with the provided data. func (a *SettingController) updateSetting(c *gin.Context) { - allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c) + form, ok := middleware.BindAndValidate[updateSettingForm](c) if !ok { return } + allSetting := &form.AllSetting oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable() oldPanelOutbound, _ := a.settingService.GetPanelOutbound() oldTgEnable, _ := a.settingService.GetTgbotEnabled() oldTgToken, _ := a.settingService.GetTgBotToken() oldTgChatId, _ := a.settingService.GetTgBotChatId() oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer() + if twoFactorErr == nil && oldTwoFactor && !allSetting.TwoFactorEnable { + if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) + return + } + } err := a.settingService.UpdateAllSetting(allSetting) if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil { err = bumpErr } } - if err == nil && allSetting.PanelOutbound != oldPanelOutbound { + if err == nil && form.PanelOutbound != oldPanelOutbound { // The egress bridge lives in the generated config; reconcile the // running core. One SOCKS inbound plus one routing rule — both // hot-appliable, so this normally does not restart Xray. @@ -136,6 +149,10 @@ func (a *SettingController) updateUser(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty"))) return } + if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), err) + return + } err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword) if err == nil { user.Username = form.NewUsername diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 276eb5cf8..ca74295b4 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -14,6 +14,8 @@ import ( "time" "github.com/google/uuid" + "github.com/xlzd/gotp" + "gorm.io/gorm" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -25,8 +27,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util" "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/xray" - - "gorm.io/gorm" ) //go:embed config.json @@ -568,6 +568,24 @@ func (s *SettingService) SetTwoFactorToken(value string) error { return s.setString("twoFactorToken", value) } +func (s *SettingService) VerifyTwoFactorCode(code string) error { + enabled, err := s.GetTwoFactorEnable() + if err != nil { + return err + } + if !enabled { + return nil + } + token, err := s.GetTwoFactorToken() + if err != nil { + return err + } + if strings.TrimSpace(token) == "" || !gotp.NewDefaultTOTP(token).Verify(strings.TrimSpace(code), time.Now().Unix()) { + return common.NewError("invalid two factor code") + } + return nil +} + func (s *SettingService) GetPort() (int, error) { return s.getInt("webPort") } diff --git a/internal/web/service/setting_security_test.go b/internal/web/service/setting_security_test.go index 1939b0e13..4acef1a6d 100644 --- a/internal/web/service/setting_security_test.go +++ b/internal/web/service/setting_security_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "testing" + "github.com/xlzd/gotp" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) @@ -100,3 +102,22 @@ func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { t.Fatalf("allowPrivate result = %q, %v", got, err) } } + +func TestVerifyTwoFactorCode(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if err := s.saveSetting("twoFactorEnable", "true"); err != nil { + t.Fatal(err) + } + const token = "JBSWY3DPEHPK3PXP" + if err := s.saveSetting("twoFactorToken", token); err != nil { + t.Fatal(err) + } + + if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil { + t.Fatalf("valid code rejected: %v", err) + } + if err := s.VerifyTwoFactorCode("000000"); err == nil { + t.Fatal("invalid code accepted") + } +}