Prevent TOTP code replay within the drift window

verify_totp called ROTP without `after:`, so a captured 6-digit code stayed
valid for the full ~90s drift window and could be replayed in a separate
sign-in. Add a last_otp_at column, pass it as ROTP's `after:`, and persist the
matched timestep on success so a code (or any earlier one) cannot be reused.

Also fixes a latent bug surfaced by the new replay path: enable_totp! did
`self.backup_codes = generate_backup_codes`, reassigning backup_codes to the
plaintext return value (generate_backup_codes already stores the BCrypt hashes
internally). That stored backup codes in plaintext and broke verification.
enable_totp! is test-only today, but it is public and backup_codes is not
encrypted, so this is a real footgun. Now it just calls generate_backup_codes.

Rewrites the mislabeled "TOTP code cannot be reused" test to actually assert
that replaying an accepted code is rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-11 08:10:34 +10:00
parent 84ed462f40
commit f38ac2ecc8
4 changed files with 29 additions and 7 deletions

View File

@@ -19,16 +19,21 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# First use of the code should succeed
post totp_verification_path, params: {code: valid_code}
assert_response :redirect
assert_redirected_to root_path
# Sign out
delete session_path
assert_response :redirect
# Note: In the current implementation, TOTP codes CAN be reused within the 60-second time window
# This is standard TOTP behavior. For enhanced security, you could implement used code tracking.
# This test documents the current behavior - codes work within their time window
# Replay the SAME code in a fresh sign-in attempt. Because verify_totp records
# the accepted timestep (ROTP `after:`), the code is now rejected even though
# it is still within its drift window — so we stay on the verification step.
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
post totp_verification_path, params: {code: valid_code}
assert_redirected_to totp_verification_path
assert_equal "Invalid verification code. Please try again.", flash[:alert]
user.sessions.delete_all
user.destroy