// 登录票据存储(两步登录①→②衔接,Redis 一次性凭据)。 // // 键空间:loginticket:{ticket} → canonical_user_id(ULID,26 字符)。 // TTL 由调用方传入(Service 层固定 5min,给前端②留够操作窗口)。 // // 安全设计(一次性 + 原子消费): // - ② 消费用 **GETDEL**(Redis 6.2+ 单命令原子 GET+DEL)—— // 同一 ticket 并发双请求时,只有一个能取到值,另一个取到 nil → 拒绝。 // 若用 GET + DEL 两步,存在 TOCTOU 竞态(两次 GET 都成功、两次都签 token = 重放漏洞)。 // - ① 签发后 ticket 即写入 Redis,**不在 JWT/响应体里保留**(响应体只回 ticket 字符串本身); // - TTL 过期后键自动删除,未②的 ticket 自然失效(无需手动清理)。 // // 依赖方向:本包属 infra/redis,不依赖 identity(依赖方向铁律)。 // 消费方(identity/service AuthService)直接 *LoginTicketStore 持有,不经过 token 包。 package redis import ( "context" "fmt" "time" gredis "github.com/redis/go-redis/v9" "yuansiwei/unified-backend/internal/infra/errors" ) // loginTicketKeyPrefix 键前缀(硬编码,与 refresh 白名单 "refresh:" 对称设计—— // 登录票据前缀固定,运维不需差异化命名;与吊销黑名单前缀走配置的差异见 revocation_store.go 注释)。 const loginTicketKeyPrefix = "loginticket:" // LoginTicketStore 登录票据存储(*Client 直接实现,装配时构造)。 type LoginTicketStore struct { *Client } // NewLoginTicketStore 构造登录票据存储。 // // 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。 // 出参:*LoginTicketStore:就绪存储。 func NewLoginTicketStore(rc *Client) *LoginTicketStore { return &LoginTicketStore{Client: rc} } // ticketKey 拼键:loginticket:{ticket}。 // ticket 为 32 字符 hex(16 字节随机),不含 ":",无需转义。 func (s *LoginTicketStore) ticketKey(ticket string) string { return loginTicketKeyPrefix + ticket } // SetTicket 写入登录票据(两步登录①验账密成功后调用)。 // // 入参: // - ctx context.Context:请求上下文。 // - ticket string:一次性票据(32 字符 hex,调用方生成)。 // - canonicalUserID string:票据对应的用户 ULID(② 消费后据此取 user + membership)。 // - ttl time.Duration:票据有效期(建议 5min;过期后键自动删除,未②即失效)。 // // 出参: // - error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。 // 调用方(AuthService.Login)必须阻断①响应(票据写失败 = 无法完成②,返回"系统繁忙")。 func (s *LoginTicketStore) SetTicket(ctx context.Context, ticket, canonicalUserID string, ttl time.Duration) error { if err := s.Client.Set(ctx, s.ticketKey(ticket), canonicalUserID, ttl).Err(); err != nil { return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写登录票据 ticket=%s 失败: %w", ticket, err)) } return nil } // GetAndDeleteTicket 原子消费登录票据(两步登录②签 token 前调用)。 // // 入参: // - ctx context.Context:请求上下文。 // - ticket string:① 返回的一次性票据。 // // 出参: // - string:canonical_user_id(ULID);票据不存在/已消费/已过期时返回 ""。 // - error: // - nil:Redis 查询成功(无论命中与否——命中返回 ULID,未命中返回 ""); // - 非 nil:Redis 故障(连接失败/超时)——**调用方(AuthService.Issue)必须阻断**, // 把本次请求按"票据失效"处理(安全红线:票据消费故障不可降级为"无票据"放行)。 // // 实现说明:用 **GETDEL**(Redis 6.2+ 单命令原子 GET+DEL)而非 GET+DEL 两步—— // 并发安全:同一 ticket 双请求并发时,只有一个能取到值,另一个取到 nil(键已删)→ 拒绝。 // go-redis v9 的 GetDel 返回 *stringCmd,Nil 语义:键不存在时 .Result() 返回 ("",gredis.Nil)。 func (s *LoginTicketStore) GetAndDeleteTicket(ctx context.Context, ticket string) (string, error) { v, err := s.Client.GetDel(ctx, s.ticketKey(ticket)).Result() switch { case err == nil: return v, nil // 命中:返回 ULID(键已被 GETDEL 原子删除) case err == gredis.Nil: return "", nil // 未命中:票据不存在/已消费/已过期(② 应报"票据已失效,请重新登录") default: return "", errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 消费登录票据 ticket=%s 失败: %w", ticket, err)) } }