mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 00:40:20 +00:00
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:
1 parent
8cb86e3853
commit
d19952a834
8 files changed
+172
-2
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user