diff --git a/cmd/dashboard/controller/jwt.go b/cmd/dashboard/controller/jwt.go index 102e39dc..522dcf70 100644 --- a/cmd/dashboard/controller/jwt.go +++ b/cmd/dashboard/controller/jwt.go @@ -1,6 +1,8 @@ package controller import ( + "crypto/sha256" + "encoding/hex" "net/http" "time" @@ -12,10 +14,51 @@ import ( "github.com/nezhahq/nezha/cmd/dashboard/controller/waf" "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/idcodec" "github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/service/singleton" ) +const ( + jwtClaimUserID = "uid" + jwtClaimKeyID = "keyId" + jwtKeyIDBytes = 32 +) + +func uaHash(c *gin.Context) string { + sum := sha256.Sum256([]byte(c.Request.UserAgent())) + return hex.EncodeToString(sum[:]) +} + +func issueJWTSession(c *gin.Context, user *model.User, jwtTimeoutHours int) (map[string]interface{}, error) { + keyID, err := utils.GenerateRandomString(jwtKeyIDBytes) + if err != nil { + return nil, err + } + hashUID, err := idcodec.Encode(user.ID) + if err != nil { + return nil, err + } + now := time.Now() + sess := model.JWTSession{ + KeyID: keyID, + UserID: user.ID, + IP: c.GetString(model.CtxKeyRealIPStr), + UAHash: uaHash(c), + TokenVersion: user.TokenVersion, + ExpiresAt: now.Add(time.Hour * time.Duration(jwtTimeoutHours)), + CreatedAt: now, + LastUsedAt: now, + } + if err := singleton.DB.Create(&sess).Error; err != nil { + return nil, err + } + return map[string]interface{}{ + jwtClaimUserID: hashUID, + jwtClaimKeyID: keyID, + }, nil +} + func initParams() *jwt.GinJWTMiddleware { return &jwt.GinJWTMiddleware{ Realm: singleton.Conf.SiteName, @@ -73,28 +116,56 @@ func identityHandler() func(c *gin.Context) any { return func(c *gin.Context) any { claims := jwt.ExtractClaims(c) - userId, ok := claims["user_id"].(string) - if !ok { + keyID, ok := claims[jwtClaimKeyID].(string) + if !ok || keyID == "" { + return nil + } + hashUID, ok := claims[jwtClaimUserID].(string) + if !ok || hashUID == "" { + return nil + } + claimUID, err := idcodec.Decode(hashUID) + if err != nil { + realIP := c.GetString(model.CtxKeyRealIPStr) + model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken) return nil } - tokenIP, ok := claims["ip"].(string) - if !ok { + var sess model.JWTSession + if err := singleton.DB.First(&sess, "key_id = ?", keyID).Error; err != nil { + return nil + } + if sess.RevokedAt != nil { + return nil + } + now := time.Now() + if now.After(sess.ExpiresAt) { + return nil + } + if claimUID != sess.UserID { + realIP := c.GetString(model.CtxKeyRealIPStr) + model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken) return nil } - currentIP := c.GetString(model.CtxKeyRealIPStr) - - if tokenIP != currentIP { - // IP地址不匹配,token无效 + if sess.IP != currentIP { c.Set(model.CtxKeyIsIPMismatch, true) return nil } var user model.User - if err := singleton.DB.First(&user, userId).Error; err != nil { + if err := singleton.DB.First(&user, sess.UserID).Error; err != nil { return nil } + if user.TokenVersion != sess.TokenVersion { + return nil + } + + _ = singleton.DB.Model(&model.JWTSession{}). + Where("key_id = ?", keyID). + Update("last_used_at", now).Error + + c.Set(jwtClaimKeyID, keyID) return &user } } @@ -118,7 +189,7 @@ func authenticator() func(c *gin.Context) (any, error) { var user model.User realip := c.GetString(model.CtxKeyRealIPStr) - if err := singleton.DB.Select("id", "password", "reject_password").Where("username = ?", loginVals.Username).First(&user).Error; err != nil { + if err := singleton.DB.Select("id", "password", "reject_password", "token_version").Where("username = ?", loginVals.Username).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { model.BlockIP(singleton.DB, realip, model.WAFBlockReasonTypeLoginFail, model.BlockIDUnknownUser) } @@ -138,11 +209,7 @@ func authenticator() func(c *gin.Context) (any, error) { model.UnblockIP(singleton.DB, realip, model.BlockIDUnknownUser) model.UnblockIP(singleton.DB, realip, int64(user.ID)) - // 返回用户ID和IP地址的组合,用于在payloadFunc中设置JWT claims - return map[string]interface{}{ - "user_id": utils.Itoa(user.ID), - "ip": realip, - }, nil + return issueJWTSession(c, &user, singleton.Conf.JWTTimeout) } } @@ -172,6 +239,14 @@ func unauthorized() func(c *gin.Context, code int, message string) { // @Success 200 {object} model.CommonResponse[model.LoginResponse] // @Router /refresh-token [get] func refreshResponse(c *gin.Context, code int, token string, expire time.Time) { + if keyID := c.GetString(jwtClaimKeyID); keyID != "" { + _ = singleton.DB.Model(&model.JWTSession{}). + Where("key_id = ?", keyID). + Updates(map[string]interface{}{ + "expires_at": expire, + "last_used_at": time.Now(), + }).Error + } c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{ Success: true, Data: model.LoginResponse{ diff --git a/cmd/dashboard/controller/jwt_session_test.go b/cmd/dashboard/controller/jwt_session_test.go new file mode 100644 index 00000000..1278e93c --- /dev/null +++ b/cmd/dashboard/controller/jwt_session_test.go @@ -0,0 +1,246 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/idcodec" + "github.com/nezhahq/nezha/service/singleton" +) + +const jwtSessionTestMasterKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func setupJWTSessionTest(t *testing.T) (cleanup func()) { + t.Helper() + require.NoError(t, idcodec.Init([]byte(jwtSessionTestMasterKey))) + + originalDB := singleton.DB + originalConf := singleton.Conf + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.JWTSession{}, &model.WAF{})) + singleton.DB = db + singleton.Conf = &singleton.ConfigClass{Config: &model.Config{JWTTimeout: 1}} + + require.NoError(t, db.Create(&model.User{ + Common: model.Common{ID: 100}, + Username: "victim", + Role: model.RoleMember, + TokenVersion: 7, + }).Error) + + return func() { + singleton.DB = originalDB + singleton.Conf = originalConf + } +} + +func newCtxForUser(userID uint64, ip, ua string) *gin.Context { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.Header.Set("User-Agent", ua) + ctx.Set(model.CtxKeyRealIPStr, ip) + if userID != 0 { + ctx.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: userID}}) + } + return ctx +} + +func TestIssueJWTSessionWritesRow(t *testing.T) { + cleanup := setupJWTSessionTest(t) + defer cleanup() + + ctx := newCtxForUser(0, "1.2.3.4", "test-ua") + user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7} + claims, err := issueJWTSession(ctx, &user, 1) + require.NoError(t, err) + + hashUID, _ := claims[jwtClaimUserID].(string) + keyID, _ := claims[jwtClaimKeyID].(string) + assert.NotEqual(t, "100", hashUID, "uid claim must be obfuscated, not raw integer") + got, err := idcodec.Decode(hashUID) + require.NoError(t, err) + assert.Equal(t, uint64(100), got) + + var sess model.JWTSession + require.NoError(t, singleton.DB.First(&sess, "key_id = ?", keyID).Error) + assert.Equal(t, uint64(100), sess.UserID) + assert.Equal(t, "1.2.3.4", sess.IP) + assert.Equal(t, uint64(7), sess.TokenVersion) + assert.True(t, sess.ExpiresAt.After(time.Now())) +} + +func TestIdentityHandlerHappyPath(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) + + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: claims[jwtClaimUserID], + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + + identity := identityHandler()(verify) + require.NotNil(t, identity, "happy path must return user identity") + u := identity.(*model.User) + assert.Equal(t, uint64(100), u.ID) +} + +func TestIdentityHandlerRejectsMismatchedClaimUID(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) + + forgedUID, err := idcodec.Encode(999) + require.NoError(t, err) + + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: forgedUID, + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + + identity := identityHandler()(verify) + assert.Nil(t, identity, "claim uid not matching session.user_id must reject") +} + +func TestIdentityHandlerRejectsRevokedSession(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) + keyID := claims[jwtClaimKeyID].(string) + require.NoError(t, singleton.RevokeJWTSession(keyID)) + + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: claims[jwtClaimUserID], + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + + identity := identityHandler()(verify) + assert.Nil(t, identity, "revoked session must reject") +} + +func TestIdentityHandlerRejectsTokenVersionBump(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) + + require.NoError(t, singleton.DB.Model(&model.User{}). + Where("id = ?", 100). + Update("token_version", 8).Error) + + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: claims[jwtClaimUserID], + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + + identity := identityHandler()(verify) + assert.Nil(t, identity, "session whose TokenVersion is stale must reject") +} + +func TestIdentityHandlerFlagsIPMismatch(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) + + verify := newCtxForUser(0, "9.9.9.9", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: claims[jwtClaimUserID], + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + + identity := identityHandler()(verify) + assert.Nil(t, identity, "IP mismatch must reject") + assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch)) +} + +func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) { + cleanup := setupJWTSessionTest(t) + defer cleanup() + + hashUID, err := idcodec.Encode(100) + require.NoError(t, err) + + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: hashUID, + jwtClaimKeyID: "this-key-id-was-never-issued", + }) + + identity := identityHandler()(verify) + assert.Nil(t, identity, "key id absent from sessions table must reject (no oracle to confirm secret)") +} + +func TestAuthenticatorPersistsCurrentTokenVersion(t *testing.T) { + cleanup := setupJWTSessionTest(t) + defer cleanup() + + pw, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost) + require.NoError(t, err) + require.NoError(t, singleton.DB.Model(&model.User{}). + Where("id = ?", 100). + Update("password", string(pw)).Error) + + ctx := newCtxForUser(0, "1.2.3.4", "ua") + body, _ := json.Marshal(model.LoginRequest{Username: "victim", Password: "correct horse"}) + ctx.Request = httptest.NewRequest("POST", "/api/v1/login", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Header.Set("User-Agent", "ua") + ctx.Set(model.CtxKeyRealIPStr, "1.2.3.4") + + data, err := authenticator()(ctx) + require.NoError(t, err) + claims, ok := data.(map[string]interface{}) + require.True(t, ok, "authenticator must return claims map") + keyID, _ := claims[jwtClaimKeyID].(string) + require.NotEmpty(t, keyID) + + var sess model.JWTSession + require.NoError(t, singleton.DB.First(&sess, "key_id = ?", keyID).Error) + assert.Equal(t, uint64(7), sess.TokenVersion, + "session must record the user's current token_version, otherwise identityHandler will reject the freshly-issued token") + + verify := newCtxForUser(0, "1.2.3.4", "ua") + verify.Set("JWT_PAYLOAD", jwt.MapClaims{ + jwtClaimUserID: claims[jwtClaimUserID], + jwtClaimKeyID: claims[jwtClaimKeyID], + }) + assert.NotNil(t, identityHandler()(verify), + "the very next request with the freshly-issued token must authenticate") +} diff --git a/cmd/dashboard/controller/oauth2.go b/cmd/dashboard/controller/oauth2.go index 31d23477..1e6f3ee7 100644 --- a/cmd/dashboard/controller/oauth2.go +++ b/cmd/dashboard/controller/oauth2.go @@ -187,10 +187,15 @@ func oauth2callback(jwtConfig *jwt.GinJWTMiddleware) func(c *gin.Context) (any, } } - tokenString, _, err := jwtConfig.TokenGenerator(map[string]interface{}{ - "user_id": fmt.Sprintf("%d", bind.UserID), - "ip": realip, - }) + var bindUser model.User + if err := singleton.DB.First(&bindUser, bind.UserID).Error; err != nil { + return nil, newGormError("%v", err) + } + claims, err := issueJWTSession(c, &bindUser, singleton.Conf.JWTTimeout) + if err != nil { + return nil, err + } + tokenString, _, err := jwtConfig.TokenGenerator(claims) if err != nil { return nil, err } diff --git a/cmd/dashboard/controller/user.go b/cmd/dashboard/controller/user.go index 66bdc647..e008f9c0 100644 --- a/cmd/dashboard/controller/user.go +++ b/cmd/dashboard/controller/user.go @@ -85,11 +85,15 @@ func updateProfile(c *gin.Context) (any, error) { user.Username = pf.NewUsername user.Password = string(hash) user.RejectPassword = pf.RejectPassword + user.TokenVersion += 1 if err := singleton.DB.Save(&user).Error; err != nil { return nil, newGormError("%v", err) } singleton.OnUserUpdate(&user) + if err := singleton.RevokeJWTSessionsByUser(user.ID); err != nil { + return nil, newGormError("%v", err) + } return nil, nil } diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 63752a2b..5ab636b8 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -24,15 +24,16 @@ import ( "github.com/nezhahq/nezha/cmd/dashboard/controller/waf" "github.com/nezhahq/nezha/cmd/dashboard/rpc" "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/idcodec" "github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/proto" "github.com/nezhahq/nezha/service/singleton" ) type DashboardCliParam struct { - Version bool // 当前版本号 - ConfigFile string // 配置文件路径 - DatabaseLocation string // Sqlite3 数据库文件路径 + Version bool + ConfigFile string + DatabaseLocation string } var ( @@ -42,7 +43,6 @@ var ( ) func initSystem(bus chan<- *model.Service) error { - // 初始化管理员账户 var usersCount int64 if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil { return err @@ -61,23 +61,28 @@ func initSystem(bus chan<- *model.Service) error { } } - // 启动 singleton 包下的所有服务 if err := singleton.LoadSingleton(bus); err != nil { return err } - // 每天的3:30 对流量记录进行清理 if _, err := singleton.CronShared.AddFunc("0 30 3 * * *", singleton.CleanMonitorHistory); err != nil { return err } - // 每小时对流量记录进行打点 if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil { return err } + + if err := singleton.StartJWTSessionGC(); err != nil { + return err + } return nil } +func initIDCodec() error { + return idcodec.Init([]byte(singleton.Conf.JWTSecretKey)) +} + // @title Nezha Monitoring API // @version 1.0 // @description Nezha Monitoring API @@ -113,6 +118,7 @@ func main() { serviceSentinelDispatchBus := make(chan *model.Service) if err := utils.FirstError(singleton.InitFrontendTemplates, func() error { return singleton.InitConfigFromPath(dashboardCliParam.ConfigFile) }, + initIDCodec, singleton.InitTimezoneAndCache, func() error { if singleton.Conf.Memory.GoMemLimitMB > 0 { diff --git a/model/jwt_session.go b/model/jwt_session.go new file mode 100644 index 00000000..397034f3 --- /dev/null +++ b/model/jwt_session.go @@ -0,0 +1,19 @@ +package model + +import "time" + +type JWTSession struct { + KeyID string `gorm:"primaryKey;type:char(64)" json:"key_id"` + UserID uint64 `gorm:"index:idx_jwt_sessions_user_revoked" json:"user_id"` + IP string `gorm:"type:varchar(64)" json:"ip"` + UAHash string `gorm:"type:char(64)" json:"ua_hash"` + TokenVersion uint64 `json:"token_version"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` + RevokedAt *time.Time `gorm:"index:idx_jwt_sessions_user_revoked" json:"revoked_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` +} + +func (JWTSession) TableName() string { + return "jwt_sessions" +} diff --git a/model/user.go b/model/user.go index 6a3ffcd0..a72b0932 100644 --- a/model/user.go +++ b/model/user.go @@ -28,6 +28,7 @@ type User struct { Role Role `json:"role,omitempty"` AgentSecret string `json:"agent_secret,omitempty" gorm:"type:char(32)"` RejectPassword bool `json:"reject_password,omitempty"` + TokenVersion uint64 `json:"-" gorm:"not null;default:0"` } type UserInfo struct { diff --git a/service/singleton/jwt_session.go b/service/singleton/jwt_session.go new file mode 100644 index 00000000..b33f5cde --- /dev/null +++ b/service/singleton/jwt_session.go @@ -0,0 +1,55 @@ +package singleton + +import ( + "log" + "time" + + "github.com/nezhahq/nezha/model" +) + +const ( + JWTSessionGCSchedule = "@every 10m" + JWTSessionRevokedRetention = 24 * time.Hour + JWTSessionExpiredGrace = 1 * time.Hour +) + +func StartJWTSessionGC() error { + if _, err := CronShared.AddFunc(JWTSessionGCSchedule, RunJWTSessionGC); err != nil { + return err + } + RunJWTSessionGC() + return nil +} + +func RunJWTSessionGC() { + if DB == nil { + return + } + now := time.Now() + + if err := DB. + Where("expires_at < ?", now.Add(-JWTSessionExpiredGrace)). + Delete(&model.JWTSession{}).Error; err != nil { + log.Printf("NEZHA>> JWTSession GC delete expired failed: %v", err) + } + + if err := DB. + Where("revoked_at IS NOT NULL AND revoked_at < ?", now.Add(-JWTSessionRevokedRetention)). + Delete(&model.JWTSession{}).Error; err != nil { + log.Printf("NEZHA>> JWTSession GC delete revoked failed: %v", err) + } +} + +func RevokeJWTSession(keyID string) error { + now := time.Now() + return DB.Model(&model.JWTSession{}). + Where("key_id = ? AND revoked_at IS NULL", keyID). + Update("revoked_at", &now).Error +} + +func RevokeJWTSessionsByUser(userID uint64) error { + now := time.Now() + return DB.Model(&model.JWTSession{}). + Where("user_id = ? AND revoked_at IS NULL", userID). + Update("revoked_at", &now).Error +} diff --git a/service/singleton/singleton.go b/service/singleton/singleton.go index ed808e30..1fbc39c8 100644 --- a/service/singleton/singleton.go +++ b/service/singleton/singleton.go @@ -94,7 +94,7 @@ func InitDBFromPath(path string) error { model.Notification{}, model.AlertRule{}, model.Service{}, model.NotificationGroupNotification{}, model.Cron{}, model.Transfer{}, model.ServerGroupServer{}, model.NAT{}, model.DDNSProfile{}, model.NotificationGroupNotification{}, - model.WAF{}, model.Oauth2Bind{}, model.ServerTransfer{}) + model.WAF{}, model.Oauth2Bind{}, model.ServerTransfer{}, model.JWTSession{}) if err != nil { return err }