From d19952a834f26a700cd042c09a9f85e97db4086e Mon Sep 17 00:00:00 2001 From: NikoCat233 Date: Sat, 12 Sep 2026 22:59:17 +0800 Subject: [PATCH] feat(auth): allow JWT IP changes by configuration (#1236) * feat(auth): allow JWT IP changes by configuration * fix(auth): make JWT IP change setting concurrency-safe * test(auth): move JWT IP change setting tests out of MCP test file --- cmd/dashboard/controller/jwt.go | 2 +- cmd/dashboard/controller/jwt_session_test.go | 55 ++++++++++++++++++ cmd/dashboard/controller/setting.go | 9 +++ .../controller/setting_jwt_ip_change_test.go | 26 +++++++++ model/config.go | 21 +++++++ model/config_test.go | 4 +- model/jwt_ip_change_atomic_test.go | 56 +++++++++++++++++++ model/setting_api.go | 1 + 8 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 cmd/dashboard/controller/setting_jwt_ip_change_test.go create mode 100644 model/jwt_ip_change_atomic_test.go diff --git a/cmd/dashboard/controller/jwt.go b/cmd/dashboard/controller/jwt.go index db0865e0..9fd7ff0c 100644 --- a/cmd/dashboard/controller/jwt.go +++ b/cmd/dashboard/controller/jwt.go @@ -152,7 +152,7 @@ func identityHandler() func(c *gin.Context) any { return nil } currentIP := c.GetString(model.CtxKeyRealIPStr) - if sess.IP != currentIP { + if !singleton.Conf.JWTIPChangeAllowed() && sess.IP != currentIP { c.Set(model.CtxKeyIsIPMismatch, true) return nil } diff --git a/cmd/dashboard/controller/jwt_session_test.go b/cmd/dashboard/controller/jwt_session_test.go index 29b5e15e..0f169cb3 100644 --- a/cmd/dashboard/controller/jwt_session_test.go +++ b/cmd/dashboard/controller/jwt_session_test.go @@ -193,6 +193,61 @@ func TestIdentityHandlerFlagsIPMismatch(t *testing.T) { assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch)) } +func TestIdentityHandlerAllowsIPMismatchWhenConfigured(t *testing.T) { + cleanup := setupJWTSessionTest(t) + defer cleanup() + singleton.Conf.SetJWTIPChangeAllowed(true) + + ctx := newCtxForUser(0, "1.2.3.4", "ua") + user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7} + claims, err := issueJWTSession(ctx, &user, 1) + require.NoError(t, err) + + verify := newCtxForUser(0, "9.9.9.9", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: claims[jwtClaimUserID], + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + + identity := identityHandler()(verify) + require.NotNil(t, identity, "IP mismatch must be accepted when allow_jwt_ip_change is enabled") + assert.False(t, verify.GetBool(model.CtxKeyIsIPMismatch)) +} + +func TestIdentityHandlerConcurrentIPChangeSetting(t *testing.T) { + cleanup := setupJWTSessionTest(t) + defer cleanup() + + ctx := newCtxForUser(0, "1.2.3.4", "ua") + user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7} + claims, err := issueJWTSession(ctx, &user, 1) + require.NoError(t, err) + + stop, done := make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-stop: + return + default: + singleton.Conf.SetJWTIPChangeAllowed(true) + singleton.Conf.SetJWTIPChangeAllowed(false) + } + } + }() + defer func() { + close(stop) + <-done + }() + + for i := 0; i < 100; i++ { + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims(claims)) + require.NotNil(t, identityHandler()(verify), "matching IP must pass during setting updates") + } +} + func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) { cleanup := setupJWTSessionTest(t) defer cleanup() diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index aac58a56..0b6b70e6 100644 --- a/cmd/dashboard/controller/setting.go +++ b/cmd/dashboard/controller/setting.go @@ -97,6 +97,9 @@ func updateConfig(c *gin.Context) (any, error) { singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification + if sf.AllowJWTIPChange != nil { + singleton.Conf.SetJWTIPChangeAllowed(*sf.AllowJWTIPChange) + } singleton.Conf.Cover = sf.Cover singleton.Conf.InstallHost = sf.InstallHost singleton.Conf.DashboardHost = sf.DashboardHost @@ -160,6 +163,12 @@ func fireMCPKillSwitch() { // the current config to avoid accidentally tripping the kill switch on // partial PATCH calls that omit enable_mcp. func resolveSettingEnableMCP(formValue *bool, current bool) bool { + return resolveOptionalBool(formValue, current) +} + +// resolveOptionalBool preserves a setting for partial PATCH requests when its +// boolean field is absent, while still allowing an explicit false value. +func resolveOptionalBool(formValue *bool, current bool) bool { if formValue == nil { return current } diff --git a/cmd/dashboard/controller/setting_jwt_ip_change_test.go b/cmd/dashboard/controller/setting_jwt_ip_change_test.go new file mode 100644 index 00000000..a3f5229b --- /dev/null +++ b/cmd/dashboard/controller/setting_jwt_ip_change_test.go @@ -0,0 +1,26 @@ +package controller + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/nezhahq/nezha/model" +) + +func TestSettingForm_AllowJWTIPChangePreservesAbsentAndDecodesExplicitValues(t *testing.T) { + var omitted model.SettingForm + require.NoError(t, json.Unmarshal([]byte(`{"site_name":"X"}`), &omitted)) + require.Nil(t, omitted.AllowJWTIPChange) + + var enabled model.SettingForm + require.NoError(t, json.Unmarshal([]byte(`{"allow_jwt_ip_change":true}`), &enabled)) + require.NotNil(t, enabled.AllowJWTIPChange) + require.True(t, *enabled.AllowJWTIPChange) + + var disabled model.SettingForm + require.NoError(t, json.Unmarshal([]byte(`{"allow_jwt_ip_change":false}`), &disabled)) + require.NotNil(t, disabled.AllowJWTIPChange) + require.False(t, *disabled.AllowJWTIPChange) +} diff --git a/model/config.go b/model/config.go index 3e766b2f..fde4bd19 100644 --- a/model/config.go +++ b/model/config.go @@ -61,6 +61,9 @@ type ConfigDashboard struct { AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"` EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 + // AllowJWTIPChange disables the IP binding check for browser login sessions. + // Keep this disabled by default so a stolen JWT remains bound to its issuing IP. + AllowJWTIPChange bool `koanf:"allow_jwt_ip_change" json:"allow_jwt_ip_change,omitempty"` EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist) @@ -103,6 +106,10 @@ type Config struct { // SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。 mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"` + // jwtIPChangeAllowed mirrors AllowJWTIPChange for concurrent authentication + // and setting updates. Keep atomic fields outside ConfigDashboard. + jwtIPChangeAllowed atomic.Bool `koanf:"-" json:"-" yaml:"-"` + // oauth2 配置 Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"` @@ -225,6 +232,8 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error { c.JWTTimeout = 1 } + c.jwtIPChangeAllowed.Store(c.AllowJWTIPChange) + if c.AgentSecretKey == "" { c.AgentSecretKey, err = utils.GenerateRandomString(32) if err != nil { @@ -252,6 +261,17 @@ func (c *Config) SetMCPEnabled(v bool) { c.mcpEnabled.Store(v) } +// JWTIPChangeAllowed reports whether JWT sessions may change IP concurrently. +func (c *Config) JWTIPChangeAllowed() bool { + return c.jwtIPChangeAllowed.Load() +} + +// SetJWTIPChangeAllowed updates only the atomic mirror. save() synchronizes +// the public field for persistence; authentication must use the accessor. +func (c *Config) SetJWTIPChangeAllowed(v bool) { + c.jwtIPChangeAllowed.Store(v) +} + // Save 保存配置文件 func (c *Config) Save() error { return c.save() @@ -320,6 +340,7 @@ func (c *Config) patchYAMLField(key string, value any) error { func (c *Config) save() error { c.EnableMCP = c.mcpEnabled.Load() + c.AllowJWTIPChange = c.jwtIPChangeAllowed.Load() data, err := yaml.Marshal(c) if err != nil { return err diff --git a/model/config_test.go b/model/config_test.go index 4242c199..d5d4b6cd 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -24,6 +24,7 @@ func TestReadConfig(t *testing.T) { {"user_template", c.UserTemplate, c.UserTemplate == "user-dist"}, {"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"}, {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""}, + {"allow_jwt_ip_change", c.AllowJWTIPChange, !c.AllowJWTIPChange}, } for _, field := range testFields { @@ -36,7 +37,7 @@ func TestReadConfig(t *testing.T) { }) t.Run("ReadFile", func(t *testing.T) { - const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick" + const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick\nallow_jwt_ip_change: true" var testFrontendTemplates = []FrontendTemplate{ {Path: "um"}, @@ -59,6 +60,7 @@ func TestReadConfig(t *testing.T) { {"admin_template", c.AdminTemplate, c.AdminTemplate == "am"}, {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"}, {"site_name", c.SiteName, c.SiteName == "lowkick"}, + {"allow_jwt_ip_change", c.AllowJWTIPChange, c.AllowJWTIPChange}, } for _, field := range testFields { diff --git a/model/jwt_ip_change_atomic_test.go b/model/jwt_ip_change_atomic_test.go new file mode 100644 index 00000000..bbb2b343 --- /dev/null +++ b/model/jwt_ip_change_atomic_test.go @@ -0,0 +1,56 @@ +package model + +import ( + "path/filepath" + "sync" + "testing" +) + +func TestJWTIPChangeAllowedPersistence(t *testing.T) { + c := &Config{} + if c.JWTIPChangeAllowed() { + t.Fatal("zero-value Config must bind JWT sessions to their IP") + } + // An absent agent secret makes Read save the config during initialization. + path := newTempConfig(t, "jwt_secret_key: test\nallow_jwt_ip_change: true\n") + if err := c.Read(path, nil); err != nil { + t.Fatal(err) + } + if !c.JWTIPChangeAllowed() { + t.Fatal("Read must initialize the atomic mirror before saving") + } + for _, want := range []bool{true, false} { + c.SetJWTIPChangeAllowed(want) + if err := c.Save(); err != nil { + t.Fatal(err) + } + reloaded := &Config{} + if err := reloaded.Read(path, nil); err != nil { + t.Fatal(err) + } + if c.AllowJWTIPChange != want || reloaded.JWTIPChangeAllowed() != want { + t.Fatalf("saved and reloaded setting must be %v", want) + } + } +} + +func TestJWTIPChangeAllowedConcurrentAccess(t *testing.T) { + c := &Config{filePath: filepath.Join(t.TempDir(), "config.yaml")} + var wg sync.WaitGroup + wg.Go(func() { + for i := 0; i < 10000; i++ { + c.SetJWTIPChangeAllowed(i%2 == 0) + } + }) + wg.Go(func() { + for i := 0; i < 10000; i++ { + _ = c.JWTIPChangeAllowed() + } + }) + for i := 0; i < 20; i++ { + if err := c.Save(); err != nil { + t.Error(err) + } + } + wg.Wait() +} diff --git a/model/setting_api.go b/model/setting_api.go index 590afb9e..bd0510a0 100644 --- a/model/setting_api.go +++ b/model/setting_api.go @@ -19,6 +19,7 @@ type SettingForm struct { AgentTLS bool `json:"tls,omitempty" validate:"optional"` EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"` + AllowJWTIPChange *bool `json:"allow_jwt_ip_change,omitempty" validate:"optional"` EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"` }