revocation_store.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. // 吊销黑名单存储(满足 token 包定义的 token.RevocationStore 接口)。
  2. //
  3. // 键空间:{revocationKeyPrefix}:{jti} → 标记存在(无 payload,存在即代表"该 jti 的 access 已被吊销")。
  4. // TTL = 该 access token 剩余有效期(由调用方计算后透传;TTL ≤ 0 时直接跳过写入——
  5. // token 本已过期,无需进黑名单,省一次 Redis 写 + 一个键的内存占用)。
  6. //
  7. // 接口契约(token 包,本实现须严格遵守):
  8. // - AddRevoked:写黑名单(改密/禁用/登出后调用);Redis 写失败必须上抛 error
  9. // ——调用方(改密场景)必须阻断改密提交(凭证已变而旧 token 未吊销 = 安全漏洞);
  10. // - IsRevoked:查黑名单(token.Verify 入口调用);
  11. // - 键存在 → (true, nil);键不存在 → (false, nil);
  12. // - Redis 故障 → (false, error)——**fail-closed**:调用方(token.Verify)必须拒绝请求,
  13. // 不可把"查不到"当作"未吊销"放行(吊销失效 = 改密后旧 token 仍可用,安全红线)。
  14. //
  15. // 前缀来源:config.Auth.RevocationKeyPrefix(可配置,默认建议 "revoked");
  16. // 不硬编码(与 refresh 白名单前缀刻意分离的对称设计:白名单前缀硬编码 "refresh:",
  17. // 黑名单前缀走配置——黑名单可能按环境/实例差异化命名,白名单必须全局一致)。
  18. package redis
  19. import (
  20. "context"
  21. "fmt"
  22. "time"
  23. "yuansiwei/unified-backend/internal/infra/errors"
  24. )
  25. // defaultRevocationKeyPrefix cfg.RevocationKeyPrefix 为空时的兜底前缀("revoked",
  26. // 对齐 token 包注释的键命名习惯)。装配方(main)应显式传配置值,此处仅为防御。
  27. const defaultRevocationKeyPrefix = "revoked"
  28. // RevocationStore 吊销黑名单存储(*Client 直接实现,装配时把 *Client 当 token.RevocationStore 传入)。
  29. //
  30. // 字段:
  31. // - Client:Redis 客户端句柄(必填)。
  32. // - prefix:键前缀(config.Auth.RevocationKeyPrefix,空时用 defaultRevocationKeyPrefix 兜底)。
  33. type RevocationStore struct {
  34. *Client
  35. prefix string
  36. }
  37. // NewRevocationStore 构造吊销黑名单存储(装配入口)。
  38. //
  39. // 入参:
  40. // - rc *Client:Redis 客户端句柄(必填)。
  41. // - prefix string:键前缀(config.Auth.RevocationKeyPrefix);空/空白时用默认 "revoked"。
  42. func NewRevocationStore(rc *Client, prefix string) *RevocationStore {
  43. if prefix == "" {
  44. prefix = defaultRevocationKeyPrefix
  45. }
  46. return &RevocationStore{Client: rc, prefix: prefix}
  47. }
  48. // revocationKey 拼吊销黑名单键。prefix + ":" + jti。
  49. // jti 为 16 字节 hex(见 token 包 newJTI),prefix 为运维配置(约定不含 ":"),无需额外转义。
  50. func (s *RevocationStore) revocationKey(jti string) string {
  51. return s.prefix + ":" + jti
  52. }
  53. // AddRevoked 写入黑名单。
  54. //
  55. // 入参:ctx / jti / ttl(见 token.RevocationStore 接口)。
  56. // 出参:
  57. // - error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误;
  58. // ttl ≤ 0 时直接返回 nil(token 本已过期,无需进黑名单——省一次写 + 省内存)。
  59. //
  60. // 异常场景:
  61. // - ttl 极大(理论上 access 剩余有效期 ≤ 30min,不可能极大):不设上限钳制,
  62. // 信任调用方(Issuer 按 accessTTL 计算剩余,不会传超大值)。
  63. func (s *RevocationStore) AddRevoked(ctx context.Context, jti string, ttl time.Duration) error {
  64. if ttl <= 0 {
  65. return nil // token 本已过期,不进黑名单(省一次写 + 省内存)
  66. }
  67. if err := s.Client.Set(ctx, s.revocationKey(jti), "1", ttl).Err(); err != nil {
  68. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写吊销黑名单 jti=%s 失败: %w", jti, err))
  69. }
  70. return nil
  71. }
  72. // IsRevoked 查询黑名单(fail-closed 语义)。
  73. //
  74. // 入参:ctx / jti(见接口)。
  75. // 出参:
  76. // - bool:true 表示该 jti 已被吊销(调用方必须拒绝请求);false 表示未吊销。
  77. // - error:
  78. // - nil:Redis 查询成功(无论命中与否);
  79. // - 非 nil:Redis 故障(连接失败/超时)——**调用方(token.Verify)必须 fail-closed**,
  80. // 把本次请求按"未通过验签"处理(响应 1002 登录态失效 + 告警),**不可**把 error 当"未吊销"放行。
  81. //
  82. // 实现说明:用 EXISTS 而非 GET——黑名单键的 payload 只是标记("1"),存在性本身就是语义,
  83. // EXISTS 比 GET 少一次 payload 读取,且不存在时返回 0 而非 nil(无需判 gredis.Nil)。
  84. func (s *RevocationStore) IsRevoked(ctx context.Context, jti string) (bool, error) {
  85. n, err := s.Client.Exists(ctx, s.revocationKey(jti)).Result()
  86. if err != nil {
  87. // fail-closed:查询失败必须上抛 error,由调用方决定拒绝(不可静默当"未吊销")
  88. return false, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 查吊销黑名单 jti=%s 失败: %w", jti, err))
  89. }
  90. return n > 0, nil
  91. }
  92. // 编译期接口断言说明:*RevocationStore 须满足 token 包定义的 token.RevocationStore 接口
  93. // (AddRevoked + IsRevoked 两方法,签名见上方实现)。本包不 import identity/token(依赖方向铁律:
  94. // identity → infra,infra 零业务依赖),故跨包接口满足在 T4-6 main 装配处通过"把 *RevocationStore
  95. // 当 token.RevocationStore 传参"隐式完成——若后续误删/改签名,main 编译期即暴露,无需本处重复断言。