| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- // 吊销黑名单存储(满足 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 编译期即暴露,无需本处重复断言。
|