Files
naibaandcloudcode e05700c2f0 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

56 lines
1.3 KiB
Go

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
}