Files
nezha_domains/cmd/dashboard/controller/jwt_session_test.go
T
naibaandcloudcode 7b54a2d5ea 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>
2026-05-26 03:51:05 +00:00

247 lines
7.6 KiB
Go

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")
}