login_ticket_store.go 4.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. // 登录票据存储(两步登录①→②衔接,Redis 一次性凭据)。
  2. //
  3. // 键空间:loginticket:{ticket} → canonical_user_id(ULID,26 字符)。
  4. // TTL 由调用方传入(Service 层固定 5min,给前端②留够操作窗口)。
  5. //
  6. // 安全设计(一次性 + 原子消费):
  7. // - ② 消费用 **GETDEL**(Redis 6.2+ 单命令原子 GET+DEL)——
  8. // 同一 ticket 并发双请求时,只有一个能取到值,另一个取到 nil → 拒绝。
  9. // 若用 GET + DEL 两步,存在 TOCTOU 竞态(两次 GET 都成功、两次都签 token = 重放漏洞)。
  10. // - ① 签发后 ticket 即写入 Redis,**不在 JWT/响应体里保留**(响应体只回 ticket 字符串本身);
  11. // - TTL 过期后键自动删除,未②的 ticket 自然失效(无需手动清理)。
  12. //
  13. // 依赖方向:本包属 infra/redis,不依赖 identity(依赖方向铁律)。
  14. // 消费方(identity/service AuthService)直接 *LoginTicketStore 持有,不经过 token 包。
  15. package redis
  16. import (
  17. "context"
  18. "fmt"
  19. "time"
  20. gredis "github.com/redis/go-redis/v9"
  21. "yuansiwei/unified-backend/internal/infra/errors"
  22. )
  23. // loginTicketKeyPrefix 键前缀(硬编码,与 refresh 白名单 "refresh:" 对称设计——
  24. // 登录票据前缀固定,运维不需差异化命名;与吊销黑名单前缀走配置的差异见 revocation_store.go 注释)。
  25. const loginTicketKeyPrefix = "loginticket:"
  26. // LoginTicketStore 登录票据存储(*Client 直接实现,装配时构造)。
  27. type LoginTicketStore struct {
  28. *Client
  29. }
  30. // NewLoginTicketStore 构造登录票据存储。
  31. //
  32. // 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。
  33. // 出参:*LoginTicketStore:就绪存储。
  34. func NewLoginTicketStore(rc *Client) *LoginTicketStore {
  35. return &LoginTicketStore{Client: rc}
  36. }
  37. // ticketKey 拼键:loginticket:{ticket}。
  38. // ticket 为 32 字符 hex(16 字节随机),不含 ":",无需转义。
  39. func (s *LoginTicketStore) ticketKey(ticket string) string {
  40. return loginTicketKeyPrefix + ticket
  41. }
  42. // SetTicket 写入登录票据(两步登录①验账密成功后调用)。
  43. //
  44. // 入参:
  45. // - ctx context.Context:请求上下文。
  46. // - ticket string:一次性票据(32 字符 hex,调用方生成)。
  47. // - canonicalUserID string:票据对应的用户 ULID(② 消费后据此取 user + membership)。
  48. // - ttl time.Duration:票据有效期(建议 5min;过期后键自动删除,未②即失效)。
  49. //
  50. // 出参:
  51. // - error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。
  52. // 调用方(AuthService.Login)必须阻断①响应(票据写失败 = 无法完成②,返回"系统繁忙")。
  53. func (s *LoginTicketStore) SetTicket(ctx context.Context, ticket, canonicalUserID string, ttl time.Duration) error {
  54. if err := s.Client.Set(ctx, s.ticketKey(ticket), canonicalUserID, ttl).Err(); err != nil {
  55. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写登录票据 ticket=%s 失败: %w", ticket, err))
  56. }
  57. return nil
  58. }
  59. // GetAndDeleteTicket 原子消费登录票据(两步登录②签 token 前调用)。
  60. //
  61. // 入参:
  62. // - ctx context.Context:请求上下文。
  63. // - ticket string:① 返回的一次性票据。
  64. //
  65. // 出参:
  66. // - string:canonical_user_id(ULID);票据不存在/已消费/已过期时返回 ""。
  67. // - error:
  68. // - nil:Redis 查询成功(无论命中与否——命中返回 ULID,未命中返回 "");
  69. // - 非 nil:Redis 故障(连接失败/超时)——**调用方(AuthService.Issue)必须阻断**,
  70. // 把本次请求按"票据失效"处理(安全红线:票据消费故障不可降级为"无票据"放行)。
  71. //
  72. // 实现说明:用 **GETDEL**(Redis 6.2+ 单命令原子 GET+DEL)而非 GET+DEL 两步——
  73. // 并发安全:同一 ticket 双请求并发时,只有一个能取到值,另一个取到 nil(键已删)→ 拒绝。
  74. // go-redis v9 的 GetDel 返回 *stringCmd,Nil 语义:键不存在时 .Result() 返回 ("",gredis.Nil)。
  75. func (s *LoginTicketStore) GetAndDeleteTicket(ctx context.Context, ticket string) (string, error) {
  76. v, err := s.Client.GetDel(ctx, s.ticketKey(ticket)).Result()
  77. switch {
  78. case err == nil:
  79. return v, nil // 命中:返回 ULID(键已被 GETDEL 原子删除)
  80. case err == gredis.Nil:
  81. return "", nil // 未命中:票据不存在/已消费/已过期(② 应报"票据已失效,请重新登录")
  82. default:
  83. return "", errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 消费登录票据 ticket=%s 失败: %w", ticket, err))
  84. }
  85. }