mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
feat(jwt): server-side session table with keyId + obfuscated uid claims
Replace the {user_id, ip} claim pair with {keyId, uid}:
- keyId is a 32-byte random id that points to a row in the new
jwt_sessions table holding the real user id, bound IP, UA hash,
TokenVersion and expiry.
- uid is the user id encoded through pkg/idcodec; mismatch between
claim uid and session.UserID trips WAF block on the caller IP.
- identityHandler now rejects unknown/revoked/expired sessions, IP
drift and stale TokenVersion. Refresh updates session.ExpiresAt.
User.TokenVersion bumps on password change and revokes outstanding
sessions, so a leaked JWT secret alone is no longer enough to forge
a token. JWTSession rows are GC'd every 10 minutes (expired + grace
or revoked >24h). OAuth2 callback shares the same issue path.
Includes regression tests for happy path, mismatched claim uid,
revoked session, TokenVersion bump, IP drift and unknown keyId.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,10 +14,51 @@ import (
|
|||||||
|
|
||||||
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
|
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/pkg/idcodec"
|
||||||
"github.com/nezhahq/nezha/pkg/utils"
|
"github.com/nezhahq/nezha/pkg/utils"
|
||||||
"github.com/nezhahq/nezha/service/singleton"
|
"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 {
|
func initParams() *jwt.GinJWTMiddleware {
|
||||||
return &jwt.GinJWTMiddleware{
|
return &jwt.GinJWTMiddleware{
|
||||||
Realm: singleton.Conf.SiteName,
|
Realm: singleton.Conf.SiteName,
|
||||||
@@ -73,28 +116,56 @@ func identityHandler() func(c *gin.Context) any {
|
|||||||
return func(c *gin.Context) any {
|
return func(c *gin.Context) any {
|
||||||
claims := jwt.ExtractClaims(c)
|
claims := jwt.ExtractClaims(c)
|
||||||
|
|
||||||
userId, ok := claims["user_id"].(string)
|
keyID, ok := claims[jwtClaimKeyID].(string)
|
||||||
if !ok {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenIP, ok := claims["ip"].(string)
|
var sess model.JWTSession
|
||||||
if !ok {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currentIP := c.GetString(model.CtxKeyRealIPStr)
|
currentIP := c.GetString(model.CtxKeyRealIPStr)
|
||||||
|
if sess.IP != currentIP {
|
||||||
if tokenIP != currentIP {
|
|
||||||
// IP地址不匹配,token无效
|
|
||||||
c.Set(model.CtxKeyIsIPMismatch, true)
|
c.Set(model.CtxKeyIsIPMismatch, true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var user model.User
|
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
|
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
|
return &user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +189,7 @@ func authenticator() func(c *gin.Context) (any, error) {
|
|||||||
var user model.User
|
var user model.User
|
||||||
realip := c.GetString(model.CtxKeyRealIPStr)
|
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 {
|
if err == gorm.ErrRecordNotFound {
|
||||||
model.BlockIP(singleton.DB, realip, model.WAFBlockReasonTypeLoginFail, model.BlockIDUnknownUser)
|
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, model.BlockIDUnknownUser)
|
||||||
model.UnblockIP(singleton.DB, realip, int64(user.ID))
|
model.UnblockIP(singleton.DB, realip, int64(user.ID))
|
||||||
|
|
||||||
// 返回用户ID和IP地址的组合,用于在payloadFunc中设置JWT claims
|
return issueJWTSession(c, &user, singleton.Conf.JWTTimeout)
|
||||||
return map[string]interface{}{
|
|
||||||
"user_id": utils.Itoa(user.ID),
|
|
||||||
"ip": realip,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +239,14 @@ func unauthorized() func(c *gin.Context, code int, message string) {
|
|||||||
// @Success 200 {object} model.CommonResponse[model.LoginResponse]
|
// @Success 200 {object} model.CommonResponse[model.LoginResponse]
|
||||||
// @Router /refresh-token [get]
|
// @Router /refresh-token [get]
|
||||||
func refreshResponse(c *gin.Context, code int, token string, expire time.Time) {
|
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]{
|
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: model.LoginResponse{
|
Data: model.LoginResponse{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -187,10 +187,15 @@ func oauth2callback(jwtConfig *jwt.GinJWTMiddleware) func(c *gin.Context) (any,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenString, _, err := jwtConfig.TokenGenerator(map[string]interface{}{
|
var bindUser model.User
|
||||||
"user_id": fmt.Sprintf("%d", bind.UserID),
|
if err := singleton.DB.First(&bindUser, bind.UserID).Error; err != nil {
|
||||||
"ip": realip,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,11 +85,15 @@ func updateProfile(c *gin.Context) (any, error) {
|
|||||||
user.Username = pf.NewUsername
|
user.Username = pf.NewUsername
|
||||||
user.Password = string(hash)
|
user.Password = string(hash)
|
||||||
user.RejectPassword = pf.RejectPassword
|
user.RejectPassword = pf.RejectPassword
|
||||||
|
user.TokenVersion += 1
|
||||||
if err := singleton.DB.Save(&user).Error; err != nil {
|
if err := singleton.DB.Save(&user).Error; err != nil {
|
||||||
return nil, newGormError("%v", err)
|
return nil, newGormError("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
singleton.OnUserUpdate(&user)
|
singleton.OnUserUpdate(&user)
|
||||||
|
if err := singleton.RevokeJWTSessionsByUser(user.ID); err != nil {
|
||||||
|
return nil, newGormError("%v", err)
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -24,15 +24,16 @@ import (
|
|||||||
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
|
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
|
||||||
"github.com/nezhahq/nezha/cmd/dashboard/rpc"
|
"github.com/nezhahq/nezha/cmd/dashboard/rpc"
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/pkg/idcodec"
|
||||||
"github.com/nezhahq/nezha/pkg/utils"
|
"github.com/nezhahq/nezha/pkg/utils"
|
||||||
"github.com/nezhahq/nezha/proto"
|
"github.com/nezhahq/nezha/proto"
|
||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DashboardCliParam struct {
|
type DashboardCliParam struct {
|
||||||
Version bool // 当前版本号
|
Version bool
|
||||||
ConfigFile string // 配置文件路径
|
ConfigFile string
|
||||||
DatabaseLocation string // Sqlite3 数据库文件路径
|
DatabaseLocation string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -42,7 +43,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func initSystem(bus chan<- *model.Service) error {
|
func initSystem(bus chan<- *model.Service) error {
|
||||||
// 初始化管理员账户
|
|
||||||
var usersCount int64
|
var usersCount int64
|
||||||
if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil {
|
if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -61,23 +61,28 @@ func initSystem(bus chan<- *model.Service) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动 singleton 包下的所有服务
|
|
||||||
if err := singleton.LoadSingleton(bus); err != nil {
|
if err := singleton.LoadSingleton(bus); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每天的3:30 对流量记录进行清理
|
|
||||||
if _, err := singleton.CronShared.AddFunc("0 30 3 * * *", singleton.CleanMonitorHistory); err != nil {
|
if _, err := singleton.CronShared.AddFunc("0 30 3 * * *", singleton.CleanMonitorHistory); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每小时对流量记录进行打点
|
|
||||||
if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil {
|
if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := singleton.StartJWTSessionGC(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initIDCodec() error {
|
||||||
|
return idcodec.Init([]byte(singleton.Conf.JWTSecretKey))
|
||||||
|
}
|
||||||
|
|
||||||
// @title Nezha Monitoring API
|
// @title Nezha Monitoring API
|
||||||
// @version 1.0
|
// @version 1.0
|
||||||
// @description Nezha Monitoring API
|
// @description Nezha Monitoring API
|
||||||
@@ -113,6 +118,7 @@ func main() {
|
|||||||
serviceSentinelDispatchBus := make(chan *model.Service)
|
serviceSentinelDispatchBus := make(chan *model.Service)
|
||||||
if err := utils.FirstError(singleton.InitFrontendTemplates,
|
if err := utils.FirstError(singleton.InitFrontendTemplates,
|
||||||
func() error { return singleton.InitConfigFromPath(dashboardCliParam.ConfigFile) },
|
func() error { return singleton.InitConfigFromPath(dashboardCliParam.ConfigFile) },
|
||||||
|
initIDCodec,
|
||||||
singleton.InitTimezoneAndCache,
|
singleton.InitTimezoneAndCache,
|
||||||
func() error {
|
func() error {
|
||||||
if singleton.Conf.Memory.GoMemLimitMB > 0 {
|
if singleton.Conf.Memory.GoMemLimitMB > 0 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type User struct {
|
|||||||
Role Role `json:"role,omitempty"`
|
Role Role `json:"role,omitempty"`
|
||||||
AgentSecret string `json:"agent_secret,omitempty" gorm:"type:char(32)"`
|
AgentSecret string `json:"agent_secret,omitempty" gorm:"type:char(32)"`
|
||||||
RejectPassword bool `json:"reject_password,omitempty"`
|
RejectPassword bool `json:"reject_password,omitempty"`
|
||||||
|
TokenVersion uint64 `json:"-" gorm:"not null;default:0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInfo struct {
|
type UserInfo struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ func InitDBFromPath(path string) error {
|
|||||||
model.Notification{}, model.AlertRule{}, model.Service{}, model.NotificationGroupNotification{},
|
model.Notification{}, model.AlertRule{}, model.Service{}, model.NotificationGroupNotification{},
|
||||||
model.Cron{}, model.Transfer{}, model.ServerGroupServer{},
|
model.Cron{}, model.Transfer{}, model.ServerGroupServer{},
|
||||||
model.NAT{}, model.DDNSProfile{}, model.NotificationGroupNotification{},
|
model.NAT{}, model.DDNSProfile{}, model.NotificationGroupNotification{},
|
||||||
model.WAF{}, model.Oauth2Bind{}, model.ServerTransfer{})
|
model.WAF{}, model.Oauth2Bind{}, model.ServerTransfer{}, model.JWTSession{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user