// 吊销黑名单存储(满足 token 包定义的 token.RevocationStore 接口)。 // // 键空间:{revocationKeyPrefix}:{jti} → 标记存在(无 payload,存在即代表"该 jti 的 access 已被吊销")。 // TTL = 该 access token 剩余有效期(由调用方计算后透传;TTL ≤ 0 时直接跳过写入—— // token 本已过期,无需进黑名单,省一次 Redis 写 + 一个键的内存占用)。 // // 接口契约(token 包,本实现须严格遵守): // - AddRevoked:写黑名单(改密/禁用/登出后调用);Redis 写失败必须上抛 error // ——调用方(改密场景)必须阻断改密提交(凭证已变而旧 token 未吊销 = 安全漏洞); // - IsRevoked:查黑名单(token.Verify 入口调用); // - 键存在 → (true, nil);键不存在 → (false, nil); // - Redis 故障 → (false, error)——**fail-closed**:调用方(token.Verify)必须拒绝请求, // 不可把"查不到"当作"未吊销"放行(吊销失效 = 改密后旧 token 仍可用,安全红线)。 // // 前缀来源:config.Auth.RevocationKeyPrefix(可配置,默认建议 "revoked"); // 不硬编码(与 refresh 白名单前缀刻意分离的对称设计:白名单前缀硬编码 "refresh:", // 黑名单前缀走配置——黑名单可能按环境/实例差异化命名,白名单必须全局一致)。 package redis import ( "context" "fmt" "time" "yuansiwei/unified-backend/internal/infra/errors" ) // defaultRevocationKeyPrefix cfg.RevocationKeyPrefix 为空时的兜底前缀("revoked", // 对齐 token 包注释的键命名习惯)。装配方(main)应显式传配置值,此处仅为防御。 const defaultRevocationKeyPrefix = "revoked" // RevocationStore 吊销黑名单存储(*Client 直接实现,装配时把 *Client 当 token.RevocationStore 传入)。 // // 字段: // - Client:Redis 客户端句柄(必填)。 // - prefix:键前缀(config.Auth.RevocationKeyPrefix,空时用 defaultRevocationKeyPrefix 兜底)。 type RevocationStore struct { *Client prefix string } // NewRevocationStore 构造吊销黑名单存储(装配入口)。 // // 入参: // - rc *Client:Redis 客户端句柄(必填)。 // - prefix string:键前缀(config.Auth.RevocationKeyPrefix);空/空白时用默认 "revoked"。 func NewRevocationStore(rc *Client, prefix string) *RevocationStore { if prefix == "" { prefix = defaultRevocationKeyPrefix } return &RevocationStore{Client: rc, prefix: prefix} } // revocationKey 拼吊销黑名单键。prefix + ":" + jti。 // jti 为 16 字节 hex(见 token 包 newJTI),prefix 为运维配置(约定不含 ":"),无需额外转义。 func (s *RevocationStore) revocationKey(jti string) string { return s.prefix + ":" + jti } // AddRevoked 写入黑名单。 // // 入参:ctx / jti / ttl(见 token.RevocationStore 接口)。 // 出参: // - error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误; // ttl ≤ 0 时直接返回 nil(token 本已过期,无需进黑名单——省一次写 + 省内存)。 // // 异常场景: // - ttl 极大(理论上 access 剩余有效期 ≤ 30min,不可能极大):不设上限钳制, // 信任调用方(Issuer 按 accessTTL 计算剩余,不会传超大值)。 func (s *RevocationStore) AddRevoked(ctx context.Context, jti string, ttl time.Duration) error { if ttl <= 0 { return nil // token 本已过期,不进黑名单(省一次写 + 省内存) } if err := s.Client.Set(ctx, s.revocationKey(jti), "1", ttl).Err(); err != nil { return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写吊销黑名单 jti=%s 失败: %w", jti, err)) } return nil } // IsRevoked 查询黑名单(fail-closed 语义)。 // // 入参:ctx / jti(见接口)。 // 出参: // - bool:true 表示该 jti 已被吊销(调用方必须拒绝请求);false 表示未吊销。 // - error: // - nil:Redis 查询成功(无论命中与否); // - 非 nil:Redis 故障(连接失败/超时)——**调用方(token.Verify)必须 fail-closed**, // 把本次请求按"未通过验签"处理(响应 1002 登录态失效 + 告警),**不可**把 error 当"未吊销"放行。 // // 实现说明:用 EXISTS 而非 GET——黑名单键的 payload 只是标记("1"),存在性本身就是语义, // EXISTS 比 GET 少一次 payload 读取,且不存在时返回 0 而非 nil(无需判 gredis.Nil)。 func (s *RevocationStore) IsRevoked(ctx context.Context, jti string) (bool, error) { n, err := s.Client.Exists(ctx, s.revocationKey(jti)).Result() if err != nil { // fail-closed:查询失败必须上抛 error,由调用方决定拒绝(不可静默当"未吊销") return false, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 查吊销黑名单 jti=%s 失败: %w", jti, err)) } return n > 0, nil } // 编译期接口断言说明:*RevocationStore 须满足 token 包定义的 token.RevocationStore 接口 // (AddRevoked + IsRevoked 两方法,签名见上方实现)。本包不 import identity/token(依赖方向铁律: // identity → infra,infra 零业务依赖),故跨包接口满足在 T4-6 main 装配处通过"把 *RevocationStore // 当 token.RevocationStore 传参"隐式完成——若后续误删/改签名,main 编译期即暴露,无需本处重复断言。