mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 16:40:21 +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
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user