Skip to content

令牌黑名单

黑名单用于在令牌过期前主动使其失效,适用于用户登出、密码修改、权限变更等场景。

工作原理

text
Revoke(token) → 提取 jti + exp → 写入 BlacklistStore
Validate(token) → 验证签名 → 检查黑名单 → 返回结果

Revoke 并非简单地把传入字符串写入黑名单,而是先执行一条安全验证链,确保只有真实签发的令牌才会被吊销:

  1. 验证签名 —— 用处理器配置的密钥重新校验令牌签名,拒绝任何被篡改或伪造的令牌
  2. 校验签发者与受众 —— 检查 issaud 是否与处理器配置匹配,防止跨域误吊销
  3. 提取 jti —— 取出令牌唯一 ID(jti)作为黑名单键;若令牌缺少 jti,返回 ErrTokenMissingID
  4. 计算 TTL 并写入存储 —— 根据令牌 exp 算出条目存活时间(见下节),将 jti 写入 BlacklistStore

签名验证是关键安全设计

为什么 Revoke 要先验签再吊销?假如直接信任调用者传入的 jti,恶意调用者就能用一个伪造的 jti任意合法用户的令牌拉黑,实施拒绝服务攻击。强制签名验证保证了「只有持有真实令牌的人才能吊销它」——这也意味着调用 Revoke 时必须传入完整的令牌字符串,而非裸 jti

RevokeIsRevoked 都不检查 exp/nbf:即便令牌已过期,仍可被吊销或查询吊销状态。这样设计是为了让审计、事后补偿吊销等场景能覆盖历史令牌。

黑名单条目 TTL

黑名单条目不会永久存在。Revoke 在写入时会根据令牌的 exp 计算条目的存活时间(TTL),令牌到期后条目随之失效并被清理。三种情况如下:

  • 令牌有 exp 声明 —— TTL 等于 exp 对应的剩余时间,条目与令牌同步失效。这是最常见的情形。
  • 令牌无 exp 声明 —— TTL 默认为 7 天,避免无过期令牌永久占用条目。
  • TTL 上限为 30 天 —— 即便令牌的 exp 是 100 年后,黑名单条目最多也只能存活 30 天。

30 天上限是 DoS 防护

30 天封顶是一道关键防线。若没有上限,攻击者可以构造超长 exp 的令牌(或滥用合法的长效令牌)批量吊销,把黑名单存储撑爆、耗尽内存。有了 30 天上限,任何单条记录的存活时间都有上界,存储规模始终可控。

此外,已过期的令牌仍可吊销:因为 Revoke 不检查 exp/nbf,你可以在令牌过期后补吊销它(例如事后审计发现风险)。这类条目的 TTL 取默认 7 天,随后由后台清理机制回收。

内置内存存储

默认使用内存存储,开箱即用:

go
cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
// 黑名单已自动启用,使用 DefaultBlacklistConfig()

配置项

go
cfg.Blacklist.CleanupInterval = 5 * time.Minute  // 清理间隔
cfg.Blacklist.MaxSize = 100000                     // 最大条目数
cfg.Blacklist.EnableAutoCleanup = true             // 自动清理
字段默认值说明
CleanupInterval5m过期条目清理间隔
MaxSize100000最大条目数
EnableAutoCleanuptrue自动清理(强制为 true)

自动清理

内置存储的 EnableAutoCleanup 始终强制为 true,防止内存无限增长。

驱逐行为

当条目数达到 MaxSize 时,写入新条目会触发驱逐,按以下顺序腾出空间:

步骤行为说明
1清理过期条目先删除所有已过期的记录
2驱逐最早过期的条目若仍满,按 exp 升序驱逐约 10%(至少 1 个)最早过期的条目
3拒绝写入若仍满,Add 返回错误,Revoke 据此返回失败

可见 MaxSize 不是「写满即停」,而会在压力下优先淘汰最该消失的条目(已过期的、最快过期的)。但极端情况下仍可能吊销失败——因此生产环境建议根据峰值吊销量调大 MaxSize,或改用外部存储。

吊销令牌

go
// 吊销
err := processor.Revoke(accessToken)
if err != nil {
    panic(err)
}

// 检查
revoked, err := processor.IsRevoked(accessToken)
fmt.Println("Revoked:", revoked) // true

// 已吊销的令牌验证会失败
_, _, err = processor.Validate(accessToken)
// err → jwt.ErrTokenRevoked

自定义存储后端

实现 BlacklistStore 接口对接外部存储(Redis、数据库等):

go
type BlacklistStore interface {
    Add(tokenID string, expiresAt time.Time) error
    Contains(tokenID string) (bool, error)
    Close() error
}

Redis 示例

go
type RedisStore struct {
    client *redis.Client
}

func (s *RedisStore) Add(tokenID string, expiresAt time.Time) error {
    ttl := time.Until(expiresAt)
    if ttl <= 0 {
        return nil // 已过期的令牌无需存储
    }
    return s.client.Set(ctx, "blacklist:"+tokenID, "1", ttl).Err()
}

func (s *RedisStore) Contains(tokenID string) (bool, error) {
    n, err := s.client.Exists(ctx, "blacklist:"+tokenID).Result()
    return n > 0, err
}

func (s *RedisStore) Close() error {
    return s.client.Close()
}

使用自定义存储:

go
cfg.Blacklist.Store = &RedisStore{client: rdb}

TTL 优化

使用 time.Until(expiresAt) 作为 Redis TTL,令牌过期后自动从黑名单中移除,无需额外清理。

Close() 的职责

Processor.Close() 在关闭时会级联调用 BlacklistStore.Close()——你无需手动关闭黑名单存储,关闭处理器即可。自定义存储的 Close() 实现应释放所有底层资源:

  • 关闭 Redis / 数据库连接
  • 停止后台 goroutine 与 ticker
  • 释放文件句柄等

上方 Redis 示例中的 s.client.Close() 就完成了连接池的清理。Close() 应当幂等——重复调用不应报错(内置存储实现已遵循此约定,二次调用直接返回 nil)。

自定义存储不受 CleanupInterval / MaxSize 约束

BlacklistConfigCleanupIntervalMaxSizeEnableAutoCleanup 仅对内置内存存储有效。一旦设置了 Store 字段改用自定义后端,这三个字段会被完全忽略——过期清理、容量上限等应由你的存储后端自行负责(如 Redis 的 TTL、数据库的定时任务)。

生产环境建议

多实例必须共享黑名单

内置内存存储不跨进程。若服务部署多个实例(Pod / 容器 / 服务器),在某实例上吊销的令牌在其他实例上仍会放行——用户登出后换个实例又被当作已登录。多实例场景必须使用 Redis、数据库等共享存储作为 BlacklistStore,保证所有实例读写同一份黑名单。

监控黑名单规模

黑名单会持续累积吊销记录,直到条目按 TTL 过期。建议监控存储大小(内存存储的条目数、Redis 的键数量)并对异常增长告警——突增往往意味着批量吊销(如安全事件)或 TTL 过长。把 MaxSize 设为略高于峰值吊销量,可避免触发驱逐导致吊销失败。

短 TTL 令牌可能不需要黑名单

若访问令牌本身有效期很短(例如 15 分钟),用户「登出」时令牌几分钟后自然过期,通常不值得为它维护黑名单——黑名单的成本(存储 + 每次验证多一次查询)可能超过收益。黑名单更适合吊销长效令牌(长效 access token、refresh token)。短 TTL 场景可考虑仅对 refresh token 启用黑名单。

其他注意事项

  • 自定义存储实现应处理网络超时和重试,避免外部存储抖动阻塞验证链路
  • MaxSize 达到上限后,新吊销的令牌会驱逐最早的条目(见上文「内置内存存储」)

下一步