|
|
@@ -1,4 +1,3 @@
|
|
|
-// Package repository
|
|
|
package repository
|
|
|
|
|
|
import (
|
|
|
@@ -43,14 +42,17 @@ func (r *RefreshTokenRepo) Create(ctx context.Context, userID, plainToken string
|
|
|
func (r *RefreshTokenRepo) GetValid(ctx context.Context, plainToken string) (*models.RefreshToken, error) {
|
|
|
tokenHash := hashToken(plainToken)
|
|
|
row := r.pool.QueryRow(ctx,
|
|
|
- `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
|
|
|
+ `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at, replaced_at
|
|
|
FROM refresh_tokens
|
|
|
- WHERE token_hash = $1 AND expires_at > now() AND revoked_at IS NULL`,
|
|
|
+ WHERE token_hash = $1
|
|
|
+ AND expires_at > now()
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ AND (replaced_at IS NULL OR replaced_at > now() - interval '30 seconds')`,
|
|
|
tokenHash,
|
|
|
)
|
|
|
|
|
|
var rt models.RefreshToken
|
|
|
- err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
|
|
|
+ err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt, &rt.ReplacedAt)
|
|
|
if err != nil {
|
|
|
if err == pgx.ErrNoRows {
|
|
|
return nil, nil
|
|
|
@@ -62,7 +64,6 @@ func (r *RefreshTokenRepo) GetValid(ctx context.Context, plainToken string) (*mo
|
|
|
|
|
|
// GetValidAndLock atomically reads a valid token and locks the row FOR UPDATE.
|
|
|
// Returns the token and a cleanup func that releases the lock on error.
|
|
|
-// Used to prevent race conditions on concurrent refresh token rotation.
|
|
|
func (r *RefreshTokenRepo) GetValidAndLock(ctx context.Context, plainToken string) (*models.RefreshToken, func(), error) {
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
|
if err != nil {
|
|
|
@@ -71,15 +72,18 @@ func (r *RefreshTokenRepo) GetValidAndLock(ctx context.Context, plainToken strin
|
|
|
|
|
|
tokenHash := hashToken(plainToken)
|
|
|
row := tx.QueryRow(ctx,
|
|
|
- `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
|
|
|
+ `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at, replaced_at
|
|
|
FROM refresh_tokens
|
|
|
- WHERE token_hash = $1 AND expires_at > now() AND revoked_at IS NULL
|
|
|
+ WHERE token_hash = $1
|
|
|
+ AND expires_at > now()
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ AND (replaced_at IS NULL OR replaced_at > now() - interval '30 seconds')
|
|
|
FOR UPDATE`,
|
|
|
tokenHash,
|
|
|
)
|
|
|
|
|
|
var rt models.RefreshToken
|
|
|
- err = row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
|
|
|
+ err = row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt, &rt.ReplacedAt)
|
|
|
if err != nil {
|
|
|
tx.Rollback(ctx)
|
|
|
if err == pgx.ErrNoRows {
|
|
|
@@ -94,8 +98,9 @@ func (r *RefreshTokenRepo) GetValidAndLock(ctx context.Context, plainToken strin
|
|
|
return &rt, cleanup, nil
|
|
|
}
|
|
|
|
|
|
-// RotateToken atomically deletes the old refresh token and creates a new one.
|
|
|
-// Must be called after GetValidAndLock within the same request lifecycle.
|
|
|
+// RotateToken atomically replaces the old refresh token and creates a new one
|
|
|
+// within a transaction. Uses soft-replace (sets replaced_at) to allow a grace
|
|
|
+// period where the old token is still accepted.
|
|
|
func (r *RefreshTokenRepo) RotateToken(ctx context.Context, oldPlainToken, newPlainToken, userID string, expiresAt time.Time) error {
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
|
if err != nil {
|
|
|
@@ -104,12 +109,15 @@ func (r *RefreshTokenRepo) RotateToken(ctx context.Context, oldPlainToken, newPl
|
|
|
defer tx.Rollback(ctx)
|
|
|
|
|
|
oldHash := hashToken(oldPlainToken)
|
|
|
- _, err = tx.Exec(ctx,
|
|
|
- `DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
|
|
+ tag, err := tx.Exec(ctx,
|
|
|
+ `UPDATE refresh_tokens SET replaced_at = now() WHERE token_hash = $1 AND replaced_at IS NULL`,
|
|
|
oldHash,
|
|
|
)
|
|
|
if err != nil {
|
|
|
- return fmt.Errorf("delete old token: %w", err)
|
|
|
+ return fmt.Errorf("replace old token: %w", err)
|
|
|
+ }
|
|
|
+ if tag.RowsAffected() == 0 {
|
|
|
+ return fmt.Errorf("old token not found or already replaced")
|
|
|
}
|
|
|
|
|
|
newHash := hashToken(newPlainToken)
|
|
|
@@ -127,14 +135,14 @@ func (r *RefreshTokenRepo) RotateToken(ctx context.Context, oldPlainToken, newPl
|
|
|
func (r *RefreshTokenRepo) GetRevoked(ctx context.Context, plainToken string) (*models.RefreshToken, error) {
|
|
|
tokenHash := hashToken(plainToken)
|
|
|
row := r.pool.QueryRow(ctx,
|
|
|
- `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
|
|
|
+ `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at, replaced_at
|
|
|
FROM refresh_tokens
|
|
|
WHERE token_hash = $1 AND revoked_at IS NOT NULL`,
|
|
|
tokenHash,
|
|
|
)
|
|
|
|
|
|
var rt models.RefreshToken
|
|
|
- err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
|
|
|
+ err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt, &rt.ReplacedAt)
|
|
|
if err != nil {
|
|
|
if err == pgx.ErrNoRows {
|
|
|
return nil, nil
|
|
|
@@ -160,30 +168,24 @@ func (r *RefreshTokenRepo) RevokeAllForUser(ctx context.Context, userID string)
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
-func (r *RefreshTokenRepo) Delete(ctx context.Context, tokenHash string) error {
|
|
|
- _, err := r.pool.Exec(ctx,
|
|
|
- `DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
|
|
- tokenHash,
|
|
|
- )
|
|
|
- return err
|
|
|
-}
|
|
|
-
|
|
|
-// DeleteIfExists atomically deletes a token and returns true if a row was removed.
|
|
|
-// Returns false if the token was already deleted by another request.
|
|
|
-func (r *RefreshTokenRepo) DeleteIfExists(ctx context.Context, tokenHash string) (bool, error) {
|
|
|
+// ReplaceIfExists soft-replaces a token (sets replaced_at) and returns true if a row was updated.
|
|
|
+// Returns false if the token was already replaced by another request.
|
|
|
+func (r *RefreshTokenRepo) ReplaceIfExists(ctx context.Context, tokenHash string) (bool, error) {
|
|
|
tag, err := r.pool.Exec(ctx,
|
|
|
- `DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
|
|
+ `UPDATE refresh_tokens SET replaced_at = now() WHERE token_hash = $1 AND replaced_at IS NULL`,
|
|
|
tokenHash,
|
|
|
)
|
|
|
if err != nil {
|
|
|
- return false, fmt.Errorf("delete if exists: %w", err)
|
|
|
+ return false, fmt.Errorf("replace if exists: %w", err)
|
|
|
}
|
|
|
return tag.RowsAffected() > 0, nil
|
|
|
}
|
|
|
|
|
|
func (r *RefreshTokenRepo) CleanupExpired(ctx context.Context) error {
|
|
|
_, err := r.pool.Exec(ctx,
|
|
|
- `DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'`,
|
|
|
+ `DELETE FROM refresh_tokens
|
|
|
+ WHERE expires_at < now() - interval '1 day'
|
|
|
+ OR (replaced_at IS NOT NULL AND replaced_at < now() - interval '1 hour')`,
|
|
|
)
|
|
|
return err
|
|
|
}
|