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 304afd9e09
commit 7b54a2d5ea
9 changed files with 438 additions and 27 deletions
+4
View File
@@ -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
}