refresh_tokens.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. // Package repository
  2. package repository
  3. import (
  4. "context"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "fmt"
  8. "time"
  9. "github.com/jackc/pgx/v5"
  10. "github.com/jackc/pgx/v5/pgxpool"
  11. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
  12. )
  13. type RefreshTokenRepo struct {
  14. pool *pgxpool.Pool
  15. }
  16. func NewRefreshTokenRepo(pool *pgxpool.Pool) *RefreshTokenRepo {
  17. return &RefreshTokenRepo{pool: pool}
  18. }
  19. func hashToken(token string) string {
  20. sum := sha256.Sum256([]byte(token))
  21. return hex.EncodeToString(sum[:])
  22. }
  23. func (r *RefreshTokenRepo) Create(ctx context.Context, userID, plainToken string, expiresAt time.Time) error {
  24. tokenHash := hashToken(plainToken)
  25. _, err := r.pool.Exec(ctx,
  26. `INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
  27. VALUES ($1, $2, $3)`,
  28. userID, tokenHash, expiresAt,
  29. )
  30. if err != nil {
  31. return fmt.Errorf("create refresh token: %w", err)
  32. }
  33. return nil
  34. }
  35. func (r *RefreshTokenRepo) GetValid(ctx context.Context, plainToken string) (*models.RefreshToken, error) {
  36. tokenHash := hashToken(plainToken)
  37. row := r.pool.QueryRow(ctx,
  38. `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
  39. FROM refresh_tokens
  40. WHERE token_hash = $1 AND expires_at > now() AND revoked_at IS NULL`,
  41. tokenHash,
  42. )
  43. var rt models.RefreshToken
  44. err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
  45. if err != nil {
  46. if err == pgx.ErrNoRows {
  47. return nil, nil
  48. }
  49. return nil, fmt.Errorf("get refresh token: %w", err)
  50. }
  51. return &rt, nil
  52. }
  53. // GetValidAndLock atomically reads a valid token and locks the row FOR UPDATE.
  54. // Returns the token and a cleanup func that releases the lock on error.
  55. // Used to prevent race conditions on concurrent refresh token rotation.
  56. func (r *RefreshTokenRepo) GetValidAndLock(ctx context.Context, plainToken string) (*models.RefreshToken, func(), error) {
  57. tx, err := r.pool.Begin(ctx)
  58. if err != nil {
  59. return nil, nil, fmt.Errorf("begin tx: %w", err)
  60. }
  61. tokenHash := hashToken(plainToken)
  62. row := tx.QueryRow(ctx,
  63. `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
  64. FROM refresh_tokens
  65. WHERE token_hash = $1 AND expires_at > now() AND revoked_at IS NULL
  66. FOR UPDATE`,
  67. tokenHash,
  68. )
  69. var rt models.RefreshToken
  70. err = row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
  71. if err != nil {
  72. tx.Rollback(ctx)
  73. if err == pgx.ErrNoRows {
  74. return nil, nil, nil
  75. }
  76. return nil, nil, fmt.Errorf("get and lock refresh token: %w", err)
  77. }
  78. cleanup := func() {
  79. tx.Rollback(ctx)
  80. }
  81. return &rt, cleanup, nil
  82. }
  83. // RotateToken atomically deletes the old refresh token and creates a new one.
  84. // Must be called after GetValidAndLock within the same request lifecycle.
  85. func (r *RefreshTokenRepo) RotateToken(ctx context.Context, oldPlainToken, newPlainToken, userID string, expiresAt time.Time) error {
  86. tx, err := r.pool.Begin(ctx)
  87. if err != nil {
  88. return fmt.Errorf("begin tx: %w", err)
  89. }
  90. defer tx.Rollback(ctx)
  91. oldHash := hashToken(oldPlainToken)
  92. _, err = tx.Exec(ctx,
  93. `DELETE FROM refresh_tokens WHERE token_hash = $1`,
  94. oldHash,
  95. )
  96. if err != nil {
  97. return fmt.Errorf("delete old token: %w", err)
  98. }
  99. newHash := hashToken(newPlainToken)
  100. _, err = tx.Exec(ctx,
  101. `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
  102. userID, newHash, expiresAt,
  103. )
  104. if err != nil {
  105. return fmt.Errorf("insert new token: %w", err)
  106. }
  107. return tx.Commit(ctx)
  108. }
  109. func (r *RefreshTokenRepo) GetRevoked(ctx context.Context, plainToken string) (*models.RefreshToken, error) {
  110. tokenHash := hashToken(plainToken)
  111. row := r.pool.QueryRow(ctx,
  112. `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
  113. FROM refresh_tokens
  114. WHERE token_hash = $1 AND revoked_at IS NOT NULL`,
  115. tokenHash,
  116. )
  117. var rt models.RefreshToken
  118. err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
  119. if err != nil {
  120. if err == pgx.ErrNoRows {
  121. return nil, nil
  122. }
  123. return nil, fmt.Errorf("get revoked refresh token: %w", err)
  124. }
  125. return &rt, nil
  126. }
  127. func (r *RefreshTokenRepo) Revoke(ctx context.Context, tokenHash string) error {
  128. _, err := r.pool.Exec(ctx,
  129. `UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
  130. tokenHash,
  131. )
  132. return err
  133. }
  134. func (r *RefreshTokenRepo) RevokeAllForUser(ctx context.Context, userID string) error {
  135. _, err := r.pool.Exec(ctx,
  136. `UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
  137. userID,
  138. )
  139. return err
  140. }
  141. func (r *RefreshTokenRepo) Delete(ctx context.Context, tokenHash string) error {
  142. _, err := r.pool.Exec(ctx,
  143. `DELETE FROM refresh_tokens WHERE token_hash = $1`,
  144. tokenHash,
  145. )
  146. return err
  147. }
  148. // DeleteIfExists atomically deletes a token and returns true if a row was removed.
  149. // Returns false if the token was already deleted by another request.
  150. func (r *RefreshTokenRepo) DeleteIfExists(ctx context.Context, tokenHash string) (bool, error) {
  151. tag, err := r.pool.Exec(ctx,
  152. `DELETE FROM refresh_tokens WHERE token_hash = $1`,
  153. tokenHash,
  154. )
  155. if err != nil {
  156. return false, fmt.Errorf("delete if exists: %w", err)
  157. }
  158. return tag.RowsAffected() > 0, nil
  159. }
  160. func (r *RefreshTokenRepo) CleanupExpired(ctx context.Context) error {
  161. _, err := r.pool.Exec(ctx,
  162. `DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'`,
  163. )
  164. return err
  165. }