fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX
Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa: - B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows - M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record - max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed - 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter) - Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family - Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests - Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
This commit is contained in:
+108
-18
@@ -231,19 +231,26 @@ func GenerateRefreshToken(ctx context.Context, userID string, role string) (stri
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyRefreshToken checks a refresh token and returns user details if valid
|
||||
// The token is consumed (deleted) upon successful verification, implementing rotation.
|
||||
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, err error) {
|
||||
// GenerateRefreshTokenInFamily creates a refresh token in the SAME rotation
|
||||
// family as its parent (the family_id returned by VerifyRefreshToken). Rotation
|
||||
// must mint the descendant in the parent's family so a replayed (already-used)
|
||||
// ancestor can revoke the ENTIRE lineage — the descendant included — instead of
|
||||
// leaving a fresh 90-day token alive after theft is detected.
|
||||
func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role string, familyID string) (string, error) {
|
||||
token, err := generateRefreshTokenString()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Store hashed version in DB with 90-day expiry, in the given family
|
||||
query := `
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
AND expires_at > NOW()
|
||||
AND NOT revoked
|
||||
RETURNING user_id, role`
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
|
||||
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, $4, NOW() + INTERVAL '90 days')
|
||||
RETURNING id`
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
return "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
@@ -251,19 +258,102 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
|
||||
var tokenID int64
|
||||
err = tx.QueryRow(ctx, query, userID, token, role, familyID).Scan(&tokenID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", fmt.Errorf("invalid or expired refresh token")
|
||||
}
|
||||
return "", "", fmt.Errorf("failed to verify refresh token: %w", err)
|
||||
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
return "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
// Token was consumed (DELETE returned it) — this is rotation
|
||||
// If a token is used twice, the second DELETE returns no rows = invalid
|
||||
return userID, role, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyRefreshToken checks a refresh token and returns user details if valid.
|
||||
// The token is consumed (marked used) upon successful verification — rotation —
|
||||
// and its family_id is returned so the caller can mint the descendant in the
|
||||
// SAME family. If an ALREADY-ROTATED token is presented again (a replay: the
|
||||
// attacker rotated it, then the victim replayed it), the entire rotation family
|
||||
// is revoked (the descendant minted at rotation dies too) and a critical admin
|
||||
// notification (reason 'refresh_token_reuse') is raised. The caller always gets
|
||||
// the generic "invalid or expired refresh token" error so reuse is never leaked.
|
||||
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, familyID string, err error) {
|
||||
query := `
|
||||
UPDATE refresh_tokens SET used_at = NOW()
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
AND expires_at > NOW()
|
||||
AND NOT revoked
|
||||
AND used_at IS NULL
|
||||
RETURNING user_id, role, family_id`
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role, &familyID)
|
||||
if err == nil {
|
||||
// Rotation: the token is marked used (kept in the row) so a later
|
||||
// replay can be detected, and its family_id is returned.
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
return userID, role, familyID, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", "", fmt.Errorf("failed to verify refresh token: %w", err)
|
||||
}
|
||||
|
||||
// The rotation UPDATE matched nothing: the token is expired, revoked, or
|
||||
// never issued — OR it was already used (replayed). A used token is theft:
|
||||
// the descendant minted at rotation would otherwise stay valid for 90 days.
|
||||
var reusedUserID, reusedFamilyID string
|
||||
reuseErr := tx.QueryRow(ctx, `
|
||||
SELECT user_id, family_id FROM refresh_tokens
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
AND used_at IS NOT NULL
|
||||
`, tokenString).Scan(&reusedUserID, &reusedFamilyID)
|
||||
|
||||
if reuseErr == nil {
|
||||
// (i) Revoke the ENTIRE family — the reused token and every descendant.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM refresh_tokens WHERE family_id = $1`, reusedFamilyID); err != nil {
|
||||
slog.Error("CRITICAL: refresh token reuse detected but family revocation failed", "userID", reusedUserID, "familyID", reusedFamilyID, "err", err)
|
||||
}
|
||||
// (ii) Surface the theft in the admin notification centre. The NOT
|
||||
// EXISTS guard keeps ONE alert per reused family until an admin
|
||||
// acknowledges it — mirroring insertCriticalPaymentNotification.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, user_id, created_at)
|
||||
SELECT 'refresh_token_reuse', $1, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM admin_notifications an
|
||||
WHERE an.reason = 'refresh_token_reuse'
|
||||
AND an.user_id = $1
|
||||
AND an.acknowledged_at IS NULL
|
||||
)
|
||||
`, reusedUserID); err != nil {
|
||||
slog.Error("CRITICAL: refresh token reuse detected but admin alert insert failed", "userID", reusedUserID, "err", err)
|
||||
}
|
||||
// Commit the family revocation + alert — NOT the deferred rollback.
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
// (iii) CRITICAL log; (iv) generic error — never leak that reuse was seen.
|
||||
slog.Error("CRITICAL: refresh token reuse detected — rotation family revoked", "userID", reusedUserID, "familyID", reusedFamilyID)
|
||||
return "", "", "", fmt.Errorf("invalid or expired refresh token")
|
||||
}
|
||||
if !errors.Is(reuseErr, pgx.ErrNoRows) {
|
||||
return "", "", "", fmt.Errorf("failed to verify refresh token: %w", reuseErr)
|
||||
}
|
||||
|
||||
// Never-issued / expired / revoked token — indistinguishable from a replay
|
||||
// to the client, as before.
|
||||
return "", "", "", fmt.Errorf("invalid or expired refresh token")
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
// First verify should succeed
|
||||
retUserID, retRole, err := VerifyRefreshToken(ctx, token)
|
||||
retUserID, retRole, _, err := VerifyRefreshToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRefreshToken() failed: %v", err)
|
||||
}
|
||||
@@ -436,7 +436,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
// Second verify with same token must fail (rotation — token consumed)
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for consumed token, got nil")
|
||||
}
|
||||
@@ -461,13 +461,13 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
||||
}
|
||||
|
||||
// First call should succeed
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
// Second call with the same token must fail
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rotated token, got nil")
|
||||
}
|
||||
@@ -476,12 +476,96 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts verifies the reuse
|
||||
// detection: generate a token → rotate it once (minting a descendant in the
|
||||
// SAME family via GenerateRefreshTokenInFamily) → present the ORIGINAL token
|
||||
// again. The replay must (i) delete the ENTIRE rotation family (the descendant
|
||||
// included) from refresh_tokens and (ii) insert an admin_notifications row with
|
||||
// reason 'refresh_token_reuse' for the user.
|
||||
func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// 1. Generate a refresh token (new family) and rotate it once.
|
||||
original, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
if familyID == "" {
|
||||
t.Fatal("expected non-empty family_id from rotation")
|
||||
}
|
||||
|
||||
// 2. Mint the descendant in the SAME family (as RefreshTokenHandler does).
|
||||
descendant, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
||||
}
|
||||
|
||||
var famCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
||||
t.Fatalf("failed to count family rows: %v", err)
|
||||
}
|
||||
if famCount != 2 {
|
||||
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
|
||||
}
|
||||
|
||||
// 3. Replay the ORIGINAL token — theft.
|
||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for replayed token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||
}
|
||||
|
||||
// (i) The entire family is revoked: the used original AND the descendant.
|
||||
var famAfter int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famAfter); err != nil {
|
||||
t.Fatalf("failed to count family rows after replay: %v", err)
|
||||
}
|
||||
if famAfter != 0 {
|
||||
t.Errorf("expected 0 refresh tokens in family after reuse (descendant killed), got %d", famAfter)
|
||||
}
|
||||
|
||||
var descHashCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
|
||||
descendant).Scan(&descHashCount); err != nil {
|
||||
t.Fatalf("failed to check descendant: %v", err)
|
||||
}
|
||||
if descHashCount != 0 {
|
||||
t.Errorf("expected descendant to be deleted, got %d rows", descHashCount)
|
||||
}
|
||||
|
||||
// (ii) An admin alert with reason 'refresh_token_reuse' exists for the user.
|
||||
var alertCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
|
||||
userID).Scan(&alertCount); err != nil {
|
||||
t.Fatalf("failed to query admin_notifications: %v", err)
|
||||
}
|
||||
if alertCount != 1 {
|
||||
t.Errorf("expected 1 'refresh_token_reuse' alert, got %d", alertCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
|
||||
// token string and expects it to fail with "invalid or expired".
|
||||
func TestVerifyRefreshToken_InvalidToken(t *testing.T) {
|
||||
ctx, _ := testtx.SetupTestTx(t)
|
||||
|
||||
_, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
|
||||
_, _, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token, got nil")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user