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:
naiba
2026-05-26 03:51:05 +00:00
co-authored by cloudcode
parent d6f381d078
commit e05700c2f0
9 changed files with 438 additions and 27 deletions
+90 -15
View File
@@ -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{