Files
杭州明婳科技 9e11f8f007 refactor: 同义可失败 API 收敛(cache/jwt/storage/ratelimit breaking change)
按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,
收敛历史双轨 API。自定义 CacheService/Storage 实现需同步更新签名(编译失败即迁移信号)。

cache:
- CacheService.Get/Exists 改为 (bool, error):命中 (true,nil)、未命中 (false,nil)、
  Redis 未就绪/命令错误/反序列化失败返回 (false,err)
- 删除 cache.GetE/cache.ExistsE/CacheGetter/CacheExistChecker/redisCache.GetE/redisCache.ExistsE
  (不保留 deprecated wrapper)
- 新增包级 cache.Get/cache.Exists(带 error);GetWithPrefix 改为 (bool,error)

jwt:
- TokenBlacklist.IsBlacklisted 改为 (bool, error),删除 IsBlacklistedE
- IsTokenRevoked 改为 (bool, error)
- ParseToken 默认从 fail-open 改为 fail-closed:黑名单后端不可检查时返回
  ErrBlacklistUnavailable 拒绝该 Token(无 Redis 部署不再支持可靠撤销)
- 删除 ParseTokenFailClosed(主 API 已 fail-closed)
- 保留 ParseTokenWithBlacklistPolicy + BlacklistPolicy/BlacklistFailOpen/BlacklistFailClosed
  供显式 fail-open(仅无 Redis 或低安全场景)

storage:
- Storage.Exists 改为 (bool, error):存在 (true,nil)、不存在 (false,nil)、
  未初始化/路径非法/穿越/后端错误返回 (false,err)
- LocalStorage.Exists 区分 os.IsNotExist 与其他 Stat 错误
- OSSStorage.Exists 区分 OSS 404/NoSuchKey 与鉴权/网络错误
- 包级 storage.Exists 同步签名,未初始化返回 ErrStorageNotInitialized
- 新增 ErrReadTooLarge:Local/OSSStorage.Get 读取超 maxReadBytes 上限原误用
  ErrInvalidPath(语义不贴切),改为 ErrReadTooLarge

ratelimit:
- NewRedisRateLimiter 加 opts ...RedisRateLimiterOption,新增 WithFailClosed(bool)
  替代原 NewRedisRateLimiterFailClosed
- RedisRateLimit/CustomRedisRateLimit/RedisRateLimitWithIdentifier 同步加 opts 参数
- 删除 NewRedisRateLimiterFailClosed/RedisRateLimitFailClosed/CustomRedisRateLimitFailClosed
- UploadRedisRateLimit 由 fail-open 改为 fail-closed(资源敏感操作,Redis 故障时拒绝)

测试与文档:
- cache_m10_internal_test.go 重写 4 个 M10 测试为 Get/Exists 契约测试,新增 mock 编译覆盖
- jwt_test.go 改 4 处;jwt_c9c_internal_test.go 改 IsTokenRevoked 2-value
- examples/full/main_test.go 加 miniredis 注入(ParseToken fail-closed 后认证需 Redis)
- middleware_test.go 改 5 处 FailClosed 调用为 WithFailClosed(true);auth_test.go 加 Redis
- storage 测试改 6 处 Exists/Get 断言为新签名/错误类型
- README/GUIDE 同步示例为新签名;CHANGELOG 新增 Breaking 升级说明与迁移示例

验证:go build / go vet / go test -race -count=1 ./... 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:22:10 +08:00

579 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package jwt_test
import (
"errors"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/alicebob/miniredis/v2"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
func setupTestConfig() {
// 设置测试配置
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour, // 1小时
},
}
config.Set(cfg)
}
func TestGenerateToken(t *testing.T) {
setupTestConfig()
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken error: %v", err)
}
if token == "" {
t.Error("GenerateToken should return non-empty token")
}
// Token 应包含三部分(用 . 分隔)
parts := splitToken(token)
if len(parts) != 3 {
t.Errorf("Token should have 3 parts, got %d", len(parts))
}
}
func TestParseToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
// 先生成 token
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 解析 token
claims, err := jwt.ParseToken(token)
if err != nil {
t.Fatalf("ParseToken error: %v", err)
}
if claims.UserID != 1 {
t.Errorf("UserID = %d, want 1", claims.UserID)
}
if claims.Username != "testuser" {
t.Errorf("Username = %s, want testuser", claims.Username)
}
if claims.Role != "admin" {
t.Errorf("Role = %s, want admin", claims.Role)
}
if claims.UserType != "super_admin" {
t.Errorf("UserType = %s, want super_admin", claims.UserType)
}
}
func TestParseTokenInvalid(t *testing.T) {
setupTestConfig()
// 无效 token
_, err := jwt.ParseToken("invalid-token")
if err == nil {
t.Error("ParseToken should fail with invalid token")
}
// 空 token
_, err = jwt.ParseToken("")
if err == nil {
t.Error("ParseToken should fail with empty token")
}
}
func TestParseTokenWrongSecret(t *testing.T) {
setupTestConfig()
// 用不同 secret 生成的 token
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 修改 secret
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "different-secret-key-12345678901234567890",
Expire: 3600,
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set wrong secret config: %v", err)
}
// 应该解析失败
_, err := jwt.ParseToken(token)
if err == nil {
t.Error("ParseToken should fail with wrong secret")
}
}
func TestRefreshToken(t *testing.T) {
setupTestConfig()
// 生成 token
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 无 Redis 时 RefreshToken 必须 fail-closedC9b 修复:旧 token 撤销失败不签发新 token
_, err := jwt.RefreshToken(token)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("RefreshToken without Redis should fail with ErrBlacklistUnavailable, got %v", err)
}
}
// setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。
// 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
_ = client.Close()
// 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
})
// 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client))
return mr
}
func TestClaimsStructure(t *testing.T) {
claims := jwt.Claims{
UserID: 1,
Username: "test",
Role: "admin",
UserType: "super_admin",
}
if claims.UserID != 1 {
t.Error("Claims UserID failed")
}
if claims.Username != "test" {
t.Error("Claims Username failed")
}
if claims.Role != "admin" {
t.Error("Claims Username failed")
}
if claims.UserType != "super_admin" {
t.Error("Claims Username failed")
}
}
func TestErrorDefinitions(t *testing.T) {
if jwt.ErrTokenExpired == nil {
t.Error("ErrTokenExpired should be defined")
}
if jwt.ErrTokenInvalid == nil {
t.Error("ErrTokenInvalid should be defined")
}
if jwt.ErrTokenMalformed == nil {
t.Error("ErrTokenMalformed should be defined")
}
if jwt.ErrTokenNotValidYet == nil {
t.Error("ErrTokenNotValidYet should be defined")
}
}
func TestTokenBlacklist(t *testing.T) {
tb := jwt.TokenBlacklist{}
// 无 Redis 时,Add 应返回 ErrBlacklistUnavailableC9a 修复:fail-closed
err := tb.Add("test-token", time.Now().Add(time.Hour))
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
}
// 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)fail-closed:错误上抛)
revoked, err := tb.IsBlacklisted("test-token")
if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err)
}
}
func TestInvalidateToken(t *testing.T) {
setupTestConfig()
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 无 Redis 时应返回 ErrBlacklistUnavailableC9a 修复:fail-closed,不再静默成功)
err := jwt.InvalidateToken(token)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err)
}
}
func splitToken(token string) []string {
count := 0
for _, c := range token {
if c == '.' {
count++
}
}
if count != 2 {
return []string{}
}
start := 0
result := make([]string, 0, 3)
for i, c := range token {
if c == '.' {
result = append(result, token[start:i])
start = i + 1
}
}
result = append(result, token[start:])
return result
}
// ===== C9b 回归:刷新令牌撤销闭环 =====
// 回归 C9bRefreshToken 成功后,旧 token 必须被拉黑(ParseToken 返 ErrTokenRevoked),
// 新 token 可用。旧实现丢弃 Add 错误仍签发新 token,旧 token 仍有效(双有效)。
func TestRefreshTokenRevokesOldToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 刷新
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
if newToken == "" {
t.Fatal("RefreshToken should return non-empty token")
}
// 新 token 可解析
newClaims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken new token: %v", err)
}
if newClaims.Username != "testuser" {
t.Error("new token claims should match original")
}
// 旧 token 必须已被拉黑(C9b 核心)
_, err = jwt.ParseToken(token)
if !errors.Is(err, jwt.ErrTokenRevoked) {
t.Errorf("old token after refresh err = %v, want ErrTokenRevoked (C9b: old must be blacklisted)", err)
}
}
// 回归 C9bRedis 抖动(Add 失败)时 RefreshToken 必须 fail-closed,不签发新 token。
// 旧实现丢弃 Add 错误仍签发新 token → 旧 token 未拉黑、新旧双有效。
func TestRefreshTokenFailsOnRedisError(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 模拟 Redis 抖动:关闭 miniredis,使 Add 的 Set 失败。
mr.Close()
_, err := jwt.RefreshToken(token)
if err == nil {
t.Fatal("RefreshToken should fail when Redis unavailable (C9b: must not issue new token)")
}
// 不应是 ErrBlacklistUnavailable(那是无 Redis 路径),而是 Add 的 Set 错误包装。
// 关键:未签发新 token,旧 token 未被拉黑(因 Add 失败)。
}
// 回归 C9a/bInvalidateToken 闭环——登出后 token 被拉黑、ParseToken 返 ErrTokenRevoked。
func TestInvalidateTokenRevokesToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 登出前可解析
if _, err := jwt.ParseToken(token); err != nil {
t.Fatalf("ParseToken before invalidate: %v", err)
}
// 登出(拉黑)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken: %v", err)
}
// 登出后必须被拉黑
_, err := jwt.ParseToken(token)
if !errors.Is(err, jwt.ErrTokenRevoked) {
t.Errorf("ParseToken after invalidate err = %v, want ErrTokenRevoked", err)
}
}
// 回归 C9a:无 Redis 时 InvalidateTokenByID 返 ErrBlacklistUnavailablefail-closed)。
func TestInvalidateTokenByIDNoRedis(t *testing.T) {
setupTestConfig()
// 不 setupMiniRedis → tokenBlacklist 指向无 Redis 的 Manager
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 还原基线
err := jwt.InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("InvalidateTokenByID without Redis err = %v, want ErrBlacklistUnavailable", err)
}
}
// ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 =====
// 回归 P0alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。
// 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。
func TestParseTokenRejectsAlgNone(t *testing.T) {
setupTestConfig()
claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"}
// 构造 alg=none 的未签名 token。
noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims)
signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("构造 none token 失败: %v", err)
}
if _, err := jwt.ParseToken(signed); err == nil {
t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)")
}
}
// 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret
// 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。
func TestEmptySecretFailsClosed(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
if !errors.Is(err, jwt.ErrEmptySecret) {
t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err)
}
// 校验侧:任意 token 在空密钥下都不得通过。
if _, err := jwt.ParseToken("a.b.c"); err == nil {
t.Error("空密钥 ParseToken 不得通过任何 token")
}
}
// 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm
// 不再静默回退 HS256。
func TestUnsupportedAlgorithmFailsClosed(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Algorithm: "RS256",
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) {
t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err)
}
}
// 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。
func TestSupportedAlgorithmHS384(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Algorithm: "HS384",
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("HS384 GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); err != nil {
t.Errorf("HS384 ParseToken: %v", err)
}
}
func TestParseTokenRejectsWrongIssuer(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
Issuer: "trusted-issuer",
}})
t.Cleanup(setupTestConfig)
claims := jwt.Claims{
UserID: 1,
Username: "attacker",
Role: "admin",
UserType: "super_admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "evil-issuer",
},
}
token := signTokenForTest(t, claims)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
}
func TestRefreshTokenUsesRefreshExpire(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
RefreshExpire: 4 * time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
claims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken refreshed token: %v", err)
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute {
t.Errorf("refreshed token ttl = %v, want about 4h", ttl)
}
}
func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) {
setupTestConfig()
for _, seconds := range []int{0, -1} {
if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) {
t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err)
}
}
}
func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err)
}
}
func TestRefreshTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) {
setupTestConfig()
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken before empty secret: %v", err)
}
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) {
t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err)
}
}
func TestParseTokenBlacklistPolicy(t *testing.T) {
setupTestConfig()
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) })
token, err := jwt.GenerateToken(1, "test", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err)
}
if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil {
t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err)
}
}
func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
claims := jwt.Claims{
UserID: 1,
Username: "external",
Role: "admin",
UserType: "admin",
JTI: "future-jti",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
Issuer: "xlgo",
ID: "future-jti",
},
}
token := signTokenForTest(t, claims)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken future nbf: %v", err)
}
if !mr.Exists("jwt_bl:future-jti") {
t.Fatal("future nbf token JTI should be blacklisted")
}
}
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
t.Helper()
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return token
}