diff --git a/Gemfile b/Gemfile index 1b4f56a..af5ad62 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 8.1.0" +gem "rails", "~> 8.1.1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use sqlite3 as the database for Active Record diff --git a/Gemfile.lock b/Gemfile.lock index 1473cc0..e5c8528 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,29 +3,29 @@ GEM specs: action_text-trix (2.1.15) railties - actioncable (8.1.0) - actionpack (= 8.1.0) - activesupport (= 8.1.0) + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.0) - actionpack (= 8.1.0) - activejob (= 8.1.0) - activerecord (= 8.1.0) - activestorage (= 8.1.0) - activesupport (= 8.1.0) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) - actionmailer (8.1.0) - actionpack (= 8.1.0) - actionview (= 8.1.0) - activejob (= 8.1.0) - activesupport (= 8.1.0) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.0) - actionview (= 8.1.0) - activesupport (= 8.1.0) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,36 +33,36 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.0) + actiontext (8.1.1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.0) - activerecord (= 8.1.0) - activestorage (= 8.1.0) - activesupport (= 8.1.0) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.0) - activesupport (= 8.1.0) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.0) - activesupport (= 8.1.0) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - activemodel (8.1.0) - activesupport (= 8.1.0) - activerecord (8.1.0) - activemodel (= 8.1.0) - activesupport (= 8.1.0) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) timeout (>= 0.4.0) - activestorage (8.1.0) - actionpack (= 8.1.0) - activejob (= 8.1.0) - activerecord (= 8.1.0) - activesupport (= 8.1.0) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (8.1.0) + activesupport (8.1.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -112,14 +112,14 @@ GEM cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crass (1.0.6) - date (3.4.1) + date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) dotenv (3.1.8) drb (2.2.3) ed25519 (1.4.0) - erb (5.1.1) + erb (5.1.3) erubi (1.13.1) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) @@ -140,14 +140,14 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.1) - irb (1.15.2) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.15.1) + json (2.15.2) jwt (3.1.2) base64 kamal (2.8.1) @@ -200,7 +200,7 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.0) - nio4r (2.7.4) + nio4r (2.7.5) nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-aarch64-linux-musl) @@ -238,7 +238,7 @@ GEM puma (7.1.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.2.3) + rack (3.2.4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -246,20 +246,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.1.0) - actioncable (= 8.1.0) - actionmailbox (= 8.1.0) - actionmailer (= 8.1.0) - actionpack (= 8.1.0) - actiontext (= 8.1.0) - actionview (= 8.1.0) - activejob (= 8.1.0) - activemodel (= 8.1.0) - activerecord (= 8.1.0) - activestorage (= 8.1.0) - activesupport (= 8.1.0) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) bundler (>= 1.15.0) - railties (= 8.1.0) + railties (= 8.1.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -267,9 +267,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.1.0) - actionpack (= 8.1.0) - activesupport (= 8.1.0) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -277,8 +277,8 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) - rdoc (6.15.0) + rake (13.3.1) + rdoc (6.15.1) erb psych (>= 4.0.0) tsort @@ -373,7 +373,7 @@ GEM thruster (0.1.16-aarch64-linux) thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-linux) - timeout (0.4.3) + timeout (0.4.4) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) @@ -387,7 +387,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.4) + uri (1.1.0) useragent (0.16.11) web-console (4.2.1) actionview (>= 6.0.0) @@ -438,7 +438,7 @@ DEPENDENCIES propshaft public_suffix (~> 6.0) puma (>= 5.0) - rails (~> 8.1.0) + rails (~> 8.1.1) rotp (~> 6.3) rqrcode (~> 3.1) rubocop-rails-omakase diff --git a/app/controllers/totp_controller.rb b/app/controllers/totp_controller.rb index dfa15c3..37d2553 100644 --- a/app/controllers/totp_controller.rb +++ b/app/controllers/totp_controller.rb @@ -24,9 +24,12 @@ class TotpController < ApplicationController if totp.verify(code, drift_behind: 30, drift_ahead: 30) # Save the secret and generate backup codes @user.totp_secret = totp_secret - @user.backup_codes = generate_backup_codes + plain_codes = @user.send(:generate_backup_codes) # Use private method from User model @user.save! + # Store plain codes temporarily in session for display after redirect + session[:temp_backup_codes] = plain_codes + # Redirect to backup codes page with success message redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now." else @@ -36,8 +39,15 @@ class TotpController < ApplicationController # GET /totp/backup_codes - Show backup codes (requires password) def backup_codes - # This will be shown after password verification - @backup_codes = @user.parsed_backup_codes + # Check if we have temporary codes from TOTP setup + if session[:temp_backup_codes].present? + @backup_codes = session[:temp_backup_codes] + session.delete(:temp_backup_codes) # Clear after use + else + # This will be shown after password verification for existing users + # Since we can't display BCrypt hashes, redirect to regenerate + redirect_to regenerate_backup_codes_totp_path + end end # POST /totp/verify_password - Verify password before showing backup codes @@ -49,6 +59,28 @@ class TotpController < ApplicationController end end + # GET /totp/regenerate_backup_codes - Regenerate backup codes (requires password) + def regenerate_backup_codes + # This will be shown after password verification + end + + # POST /totp/regenerate_backup_codes - Actually regenerate backup codes + def create_new_backup_codes + unless @user.authenticate(params[:password]) + redirect_to regenerate_backup_codes_totp_path, alert: "Incorrect password." + return + end + + # Generate new backup codes and store BCrypt hashes + plain_codes = @user.send(:generate_backup_codes) + @user.save! + + # Store plain codes temporarily in session for display + session[:temp_backup_codes] = plain_codes + + redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!" + end + # DELETE /totp - Disable TOTP (requires password) def destroy unless @user.authenticate(params[:password]) @@ -77,8 +109,4 @@ class TotpController < ApplicationController redirect_to profile_path, alert: "Two-factor authentication is not enabled." end end - - def generate_backup_codes - Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json - end end diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index 4d5beca..5ee48b9 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -3,6 +3,9 @@ import { Controller } from "@hotwired/stimulus" // Generic modal controller for showing/hiding modal dialogs export default class extends Controller { static targets = ["dialog"] + static values = { + refreshOnClose: { type: Boolean, default: false } + } show(event) { // If called from a button with data-modal-id, find and show that modal @@ -11,6 +14,8 @@ export default class extends Controller { const modal = document.getElementById(modalId); if (modal) { modal.classList.remove("hidden"); + // Store the refresh preference from the button + this.refreshOnCloseValue = event.currentTarget?.dataset?.refreshOnClose === "true"; } } else if (this.hasDialogTarget) { // Otherwise show the dialog target @@ -22,11 +27,20 @@ export default class extends Controller { } hide() { - if (this.hasDialogTarget) { + // Find the currently visible modal to hide it + const visibleModal = document.querySelector('[data-controller="modal"] .fixed.inset-0:not(.hidden)'); + if (visibleModal) { + visibleModal.classList.add("hidden"); + } else if (this.hasDialogTarget) { this.dialogTarget.classList.add("hidden"); } else { this.element.classList.add("hidden"); } + + // Refresh page if requested + if (this.refreshOnCloseValue) { + window.location.reload(); + } } // Close modal when clicking backdrop diff --git a/app/models/user.rb b/app/models/user.rb index abed8bf..8cbbabd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -66,19 +66,53 @@ class User < ApplicationRecord def verify_backup_code(code) return false unless backup_codes.present? - codes = JSON.parse(backup_codes) - if codes.include?(code) - codes.delete(code) - update(backup_codes: codes.to_json) + # Rate limiting: prevent brute force attacks on backup codes + if rate_limit_backup_code_verification? + Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}" + return false + end + + # backup_codes is now an Array (JSON column), no need to parse + # Find the matching hash by comparing with BCrypt + matching_hash = backup_codes.find do |hashed_code| + BCrypt::Password.new(hashed_code) == code + end + + if matching_hash + # Remove the used hash from the array (single-use property) + backup_codes.delete(matching_hash) + save! # Save the updated array + + # Log successful backup code usage for security monitoring + Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}" true else + # Increment failed attempt counter and log for security monitoring + increment_backup_code_failed_attempts + Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}" false end end - def parsed_backup_codes - return [] unless backup_codes.present? - JSON.parse(backup_codes) + # Rate limiting for backup code verification to prevent brute force attacks + def rate_limit_backup_code_verification? + # Use Rails.cache to track failed attempts + cache_key = "backup_code_failed_attempts_#{id}" + attempts = Rails.cache.read(cache_key) || 0 + + if attempts >= 5 # Allow max 5 failed attempts per hour + true + else + # Don't increment here - increment only on failed attempts + false + end + end + + # Increment failed attempt counter + def increment_backup_code_failed_attempts + cache_key = "backup_code_failed_attempts_#{id}" + attempts = Rails.cache.read(cache_key) || 0 + Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour) end # WebAuthn methods @@ -152,6 +186,16 @@ class User < ApplicationRecord private def generate_backup_codes - Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json + # Generate plain codes for user to see/save + plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase } + + # Store BCrypt hashes of the codes + hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) } + + # Return plain codes for display (will be shown to user once) + # Store only hashes in the database (as Array for JSON column) + self.backup_codes = hashed_codes + + plain_codes end end diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index 9e50c98..e253afd 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %> +<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %> <% if application.errors.any? %>
@@ -42,14 +42,18 @@
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %> - <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %> + <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, { + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", + disabled: application.persisted?, + data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" } + } %> <% if application.persisted? %>

Application type cannot be changed after creation.

<% end %>
-
+

OIDC Configuration

@@ -60,7 +64,7 @@
-
+

Forward Auth Configuration

@@ -120,30 +124,3 @@
<% end %> - diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2c50951..6d1f441 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -26,10 +26,13 @@ <% if authenticated? %> <%= render "shared/sidebar" %> -
+
- -
- diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb deleted file mode 100644 index 10b1ccc..0000000 --- a/config/initializers/content_security_policy.rb +++ /dev/null @@ -1,77 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header - -Rails.application.configure do - config.content_security_policy do |policy| - # Default policy: only allow resources from same origin and HTTPS - policy.default_src :self, :https - - # Scripts: strict security with nonce support for dynamic content - policy.script_src :self, :https, :strict_dynamic - - # Styles: allow inline styles for CSS frameworks, but require HTTPS - policy.style_src :self, :https, :unsafe_inline - - # Images: allow data URIs for inline images and HTTPS sources - policy.img_src :self, :https, :data - - # Fonts: allow self-hosted and HTTPS fonts, plus data URIs - policy.font_src :self, :https, :data - - # Media: allow self and HTTPS media sources - policy.media_src :self, :https - - # Objects: block potentially dangerous plugins - policy.object_src :none - - # Base URI: restrict base tag to same origin - policy.base_uri :self - - # Form actions: only allow forms to submit to same origin - policy.form_action :self - - # Frame ancestors: prevent clickjacking by disallowing framing - policy.frame_ancestors :none - - # Frame sources: block iframes unless explicitly needed - policy.frame_src :none - - # Connect sources: control where XHR/Fetch can connect - policy.connect_src :self, :https - - # Manifest: only allow same-origin manifest files - policy.manifest_src :self - - # Worker sources: control web worker origins - policy.worker_src :self, :https - - # Report URI: send violation reports to our monitoring endpoint - if Rails.env.production? - policy.report_uri "/api/csp-violation-report" - end - end - - # Generate session nonces for permitted inline scripts and styles - config.content_security_policy_nonce_generator = ->(request) { - # Use a secure random nonce instead of session ID for better security - SecureRandom.base64(16) - } - - # Apply nonces to script and style directives - config.content_security_policy_nonce_directives = %w(script-src style-src) - - # Automatically add `nonce` attributes to script/style tags - config.content_security_policy_nonce_auto = true - - # Enforce CSP in production, but use report-only in development for debugging - if Rails.env.production? - # Enforce the policy in production - config.content_security_policy_report_only = false - else - # Report violations only in development (helps with debugging) - config.content_security_policy_report_only = true - end -end diff --git a/config/routes.rb b/config/routes.rb index 21c8c55..6cbb3ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,6 +64,8 @@ Rails.application.routes.draw do delete '/totp', to: 'totp#destroy' get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp + get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp + post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp # WebAuthn (Passkeys) routes get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn diff --git a/db/schema.rb b/db/schema.rb index 0ffbda7..8500aba 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do +ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do create_table "application_groups", force: :cascade do |t| t.integer "application_id", null: false t.datetime "created_at", null: false @@ -124,7 +124,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do create_table "users", force: :cascade do |t| t.boolean "admin", default: false, null: false - t.text "backup_codes" + t.json "backup_codes" t.datetime "created_at", null: false t.json "custom_claims", default: {}, null: false t.string "email_address", null: false diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 2bc0ed7..44b8709 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -230,4 +230,132 @@ class UserTest < ActiveSupport::TestCase assert_not user.valid? assert_includes user.errors[:password], "is too short (minimum is 8 characters)" end + + # Backup codes tests + test "generate_backup_codes returns 10 plain codes and stores BCrypt hashes" do + user = User.create!( + email_address: "test@example.com", + password: "password123" + ) + + # Generate backup codes + plain_codes = user.send(:generate_backup_codes) + + # Should return 10 plain codes + assert_equal 10, plain_codes.length + assert_kind_of Array, plain_codes + + # All codes should be 8 characters, alphanumeric, uppercase + plain_codes.each do |code| + assert_equal 8, code.length + assert_match(/\A[A-Z0-9]+\z/, code) + end + + # Save user to persist the backup codes + user.save! + + # Reload user from database to check stored values + user.reload + stored_hashes = user.backup_codes || [] + + # Should store 10 BCrypt hashes + assert_equal 10, stored_hashes.length + stored_hashes.each do |hash| + assert hash.start_with?('$2a$'), "Should be BCrypt hash" + end + + # Verify each plain code matches its corresponding hash + plain_codes.each_with_index do |code, index| + assert BCrypt::Password.new(stored_hashes[index]) == code, "Plain code should match stored hash" + end + end + + test "verify_backup_code works with BCrypt hashes" do + user = User.create!( + email_address: "test@example.com", + password: "password123" + ) + + # Generate backup codes using the new flow (simulate what happens in controller) + plain_codes = user.send(:generate_backup_codes) + user.save! + user.reload + + # Should successfully verify a valid backup code + assert user.verify_backup_code(plain_codes.first), "Should verify first backup code" + + # Code should be deleted after use (single-use property) + user.reload + assert user.verify_backup_code(plain_codes.first) == false, "Used code should not be verifiable again" + + # Should still verify other unused codes + assert user.verify_backup_code(plain_codes.second), "Should verify second backup code" + end + + test "verify_backup_code returns false for invalid codes" do + user = User.create!( + email_address: "test@example.com", + password: "password123" + ) + + # Generate backup codes + plain_codes = user.send(:generate_backup_codes) + user.save! + user.reload + + # Should fail for invalid codes + assert_not user.verify_backup_code("INVALID123"), "Should fail for invalid code" + assert_not user.verify_backup_code(""), "Should fail for empty code" + assert_not user.verify_backup_code(plain_codes.first + "X"), "Should fail for modified valid code" + end + + test "verify_backup_code returns false when no backup codes exist" do + user = User.create!( + email_address: "test@example.com", + password: "password123" + ) + + # Should return false when user has no backup codes + assert_not user.verify_backup_code("ANYCODE123"), "Should fail when no backup codes exist" + end + + test "verify_backup_code respects rate limiting" do + # Temporarily use memory store for this test + original_cache_store = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + + user = User.create!( + email_address: "test@example.com", + password: "password123" + ) + + # Generate backup codes + plain_codes = user.send(:generate_backup_codes) + user.save! + user.reload + + # Make 5 failed attempts to trigger rate limit + 5.times do |i| + result = user.verify_backup_code("INVALID123") + assert_not result, "Failed attempt #{i+1} should return false" + end + + # Check that the cache is tracking attempts + attempts = Rails.cache.read("backup_code_failed_attempts_#{user.id}") || 0 + assert_equal 5, attempts, "Should have 5 failed attempts tracked" + + # 6th attempt should be rate limited (both valid and invalid codes should fail) + assert_not user.verify_backup_code(plain_codes.first), "Valid code should be rate limited after 5 failed attempts" + assert_not user.verify_backup_code("INVALID123"), "Invalid code should also be rate limited" + + # Valid code should still work if we clear the rate limit + Rails.cache.delete("backup_code_failed_attempts_#{user.id}") + assert user.verify_backup_code(plain_codes.first), "Should work after clearing rate limit" + + # Restore original cache store + Rails.cache = original_cache_store + end + + # Note: parsed_backup_codes method and legacy tests removed + # All users now use BCrypt hashes stored in JSON column end