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
This commit is contained in:
NikoCat233 authored and GitHub committed 2026-09-12 22:59:17 +08:00
1 parent 8cb86e3853
commit d19952a834
8 files changed
+172 -2

No files matched your search

+1 -1
View File
@@ -152,7 +152,7 @@ func identityHandler() func(c *gin.Context) any {
return nil return nil
} }
currentIP := c.GetString(model.CtxKeyRealIPStr) currentIP := c.GetString(model.CtxKeyRealIPStr)
if sess.IP != currentIP { if !singleton.Conf.JWTIPChangeAllowed() && sess.IP != currentIP {
c.Set(model.CtxKeyIsIPMismatch, true) c.Set(model.CtxKeyIsIPMismatch, true)
return nil return nil
} }
@@ -193,6 +193,61 @@ func TestIdentityHandlerFlagsIPMismatch(t *testing.T) {
assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch)) 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) { func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) {
cleanup := setupJWTSessionTest(t) cleanup := setupJWTSessionTest(t)
defer cleanup() defer cleanup()
+9
View File
@@ -97,6 +97,9 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
if sf.AllowJWTIPChange != nil {
singleton.Conf.SetJWTIPChangeAllowed(*sf.AllowJWTIPChange)
}
singleton.Conf.Cover = sf.Cover singleton.Conf.Cover = sf.Cover
singleton.Conf.InstallHost = sf.InstallHost singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.DashboardHost = sf.DashboardHost singleton.Conf.DashboardHost = sf.DashboardHost
@@ -160,6 +163,12 @@ func fireMCPKillSwitch() {
// the current config to avoid accidentally tripping the kill switch on // the current config to avoid accidentally tripping the kill switch on
// partial PATCH calls that omit enable_mcp. // partial PATCH calls that omit enable_mcp.
func resolveSettingEnableMCP(formValue *bool, current bool) bool { 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 { if formValue == nil {
return current return current
} }
@@ -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)
}
+21
View File
@@ -61,6 +61,9 @@ type ConfigDashboard struct {
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"` 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不打码 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) EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist)
@@ -103,6 +106,10 @@ type Config struct {
// SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。 // SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。
mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"` 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 配置
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"` 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.JWTTimeout = 1
} }
c.jwtIPChangeAllowed.Store(c.AllowJWTIPChange)
if c.AgentSecretKey == "" { if c.AgentSecretKey == "" {
c.AgentSecretKey, err = utils.GenerateRandomString(32) c.AgentSecretKey, err = utils.GenerateRandomString(32)
if err != nil { if err != nil {
@@ -252,6 +261,17 @@ func (c *Config) SetMCPEnabled(v bool) {
c.mcpEnabled.Store(v) 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 保存配置文件 // Save 保存配置文件
func (c *Config) Save() error { func (c *Config) Save() error {
return c.save() return c.save()
@@ -320,6 +340,7 @@ func (c *Config) patchYAMLField(key string, value any) error {
func (c *Config) save() error { func (c *Config) save() error {
c.EnableMCP = c.mcpEnabled.Load() c.EnableMCP = c.mcpEnabled.Load()
c.AllowJWTIPChange = c.jwtIPChangeAllowed.Load()
data, err := yaml.Marshal(c) data, err := yaml.Marshal(c)
if err != nil { if err != nil {
return err return err
+3 -1
View File
@@ -24,6 +24,7 @@ func TestReadConfig(t *testing.T) {
{"user_template", c.UserTemplate, c.UserTemplate == "user-dist"}, {"user_template", c.UserTemplate, c.UserTemplate == "user-dist"},
{"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"}, {"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"},
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""}, {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""},
{"allow_jwt_ip_change", c.AllowJWTIPChange, !c.AllowJWTIPChange},
} }
for _, field := range testFields { for _, field := range testFields {
@@ -36,7 +37,7 @@ func TestReadConfig(t *testing.T) {
}) })
t.Run("ReadFile", func(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{ var testFrontendTemplates = []FrontendTemplate{
{Path: "um"}, {Path: "um"},
@@ -59,6 +60,7 @@ func TestReadConfig(t *testing.T) {
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"}, {"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"}, {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
{"site_name", c.SiteName, c.SiteName == "lowkick"}, {"site_name", c.SiteName, c.SiteName == "lowkick"},
{"allow_jwt_ip_change", c.AllowJWTIPChange, c.AllowJWTIPChange},
} }
for _, field := range testFields { for _, field := range testFields {
+56
View File
@@ -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()
}
+1
View File
@@ -19,6 +19,7 @@ type SettingForm struct {
AgentTLS bool `json:"tls,omitempty" validate:"optional"` AgentTLS bool `json:"tls,omitempty" validate:"optional"`
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_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"` EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"`
} }