refresh_tokens.go 5.8 KB

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