fix(settings): require re-2FA confirmation for sensitive setting changes (#5610)

* fix(settings): require server-side 2fa for sensitive changes

* fix(lint): group third-party imports separately from local (goimports)

golangci-lint goimports flagged setting.go and setting_security_test.go because xlzd/gotp and gorm.io/gorm were mixed into the github.com/mhsanaei/3x-ui local-prefix group. Move them into the third-party group so the local imports stand alone.
This commit is contained in:
n0ctal
2026-06-28 18:17:15 +05:00
committed by GitHub
parent 25a86b9ee2
commit 2b10808fbd
7 changed files with 97 additions and 23 deletions
+23 -6
View File
@@ -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
+20 -2
View File
@@ -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")
}
@@ -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")
}
}