| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394 |
- // 登录票据存储(两步登录①→②衔接,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))
- }
- }
|