From ac3385aa69a46c77635fcff38970e65eb8a6aa8b Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 10 Aug 2026 14:23:38 +0900 Subject: [PATCH 1/3] Use the supported update API for Rails 7 OTP flows Rails 7 removes Active Record's deprecated update_attributes method. Direct OTP issuance and cleanup would therefore fail before an authentication flow could complete. Switch these calls to update and rename the non-persisted GuestUser test double's matching method so the test harness continues to mirror the production interface. --- .../models/two_factor_authenticatable.rb | 4 ++-- spec/rails_app/app/models/guest_user.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/two_factor_authentication/models/two_factor_authenticatable.rb b/lib/two_factor_authentication/models/two_factor_authenticatable.rb index eb6398c8..c9a4b6cf 100644 --- a/lib/two_factor_authentication/models/two_factor_authenticatable.rb +++ b/lib/two_factor_authentication/models/two_factor_authenticatable.rb @@ -98,7 +98,7 @@ def generate_totp_secret def create_direct_otp(options = {}) # Create a new random OTP and store it in the database digits = options[:length] || self.class.direct_otp_length || 6 - update_attributes( + update( direct_otp: random_base10(digits), direct_otp_sent_at: Time.now.utc ) @@ -119,7 +119,7 @@ def direct_otp_expired? end def clear_direct_otp - update_attributes(direct_otp: nil, direct_otp_sent_at: nil) + update(direct_otp: nil, direct_otp_sent_at: nil) end end diff --git a/spec/rails_app/app/models/guest_user.rb b/spec/rails_app/app/models/guest_user.rb index 8003624c..1222279a 100644 --- a/spec/rails_app/app/models/guest_user.rb +++ b/spec/rails_app/app/models/guest_user.rb @@ -7,7 +7,7 @@ class GuestUser attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email, :second_factor_attempts_count, :totp_timestamp - def update_attributes(attrs) + def update(attrs) attrs.each do |key, value| send(key.to_s + '=', value) end From c4f9bd9096c6d8a06af7ce4f1768dd289e20813a Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 10 Aug 2026 14:23:58 +0900 Subject: [PATCH 2/3] Adapt TOTP persistence and routes for Rails 8 With the newer ROTP versions required by the Rails 8 matrix, verification returns a Unix timestamp while the persisted column expects a datetime. Convert it explicitly to UTC Time before assignment. Rails 8 also validates the resource action list strictly: resend_code is declared as a separate collection route and must not be listed as a resource action. Keep that endpoint while limiting the resource actions to show and update. --- .../models/two_factor_authenticatable.rb | 2 +- lib/two_factor_authentication/routes.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/two_factor_authentication/models/two_factor_authenticatable.rb b/lib/two_factor_authentication/models/two_factor_authenticatable.rb index c9a4b6cf..5a3eb083 100644 --- a/lib/two_factor_authentication/models/two_factor_authenticatable.rb +++ b/lib/two_factor_authentication/models/two_factor_authenticatable.rb @@ -44,7 +44,7 @@ def authenticate_totp(code, options = {}) drift_ahead: drift, drift_behind: drift, after: totp_timestamp ) return false unless new_timestamp - self.totp_timestamp = new_timestamp + self.totp_timestamp = Time.at(new_timestamp).utc true end diff --git a/lib/two_factor_authentication/routes.rb b/lib/two_factor_authentication/routes.rb index 543059a2..5e5442ba 100644 --- a/lib/two_factor_authentication/routes.rb +++ b/lib/two_factor_authentication/routes.rb @@ -3,7 +3,7 @@ class Mapper protected def devise_two_factor_authentication(mapping, controllers) - resource :two_factor_authentication, :only => [:show, :update, :resend_code], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do + resource :two_factor_authentication, :only => [:show, :update], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do collection { get "resend_code" } end end From 6fac7ac8510c094bdd6e9cacb644721493471579 Mon Sep 17 00:00:00 2001 From: relsett Date: Mon, 24 Aug 2026 20:22:03 +0300 Subject: [PATCH 3/3] LT-53534: test Rails 8 compatibility --- Gemfile | 3 ++ ...wo_factor_authentication_generator_spec.rb | 2 +- .../models/two_factor_authenticatable_spec.rb | 42 ++++++++++++++++++- .../two_factor_authentication/routes_spec.rb | 10 +++++ spec/rails_app/config/application.rb | 8 ---- .../config/environments/development.rb | 5 --- .../config/environments/production.rb | 12 ------ .../config/initializers/inflections.rb | 4 ++ two_factor_authentication.gemspec | 2 +- 9 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 spec/lib/two_factor_authentication/routes_spec.rb diff --git a/Gemfile b/Gemfile index 810ce296..631027a4 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,9 @@ rails = case rails_version gem "rails", rails +gem "devise", ENV["DEVISE_VERSION"] if ENV["DEVISE_VERSION"] +gem "rotp", ENV["ROTP_VERSION"] if ENV["ROTP_VERSION"] + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.2.0') gem "test-unit", "~> 3.0" end diff --git a/spec/generators/active_record/two_factor_authentication_generator_spec.rb b/spec/generators/active_record/two_factor_authentication_generator_spec.rb index 5a8989d0..3103770e 100644 --- a/spec/generators/active_record/two_factor_authentication_generator_spec.rb +++ b/spec/generators/active_record/two_factor_authentication_generator_spec.rb @@ -23,7 +23,7 @@ describe 'the migration' do subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') } - it { is_expected.to exist } + it { is_expected.to satisfy { |path| File.exist?(path) } } it { is_expected.to be_a_migration } it { is_expected.to contain /def change/ } it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ } diff --git a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb index 6fb4f505..8e9498f1 100644 --- a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +++ b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb @@ -105,6 +105,41 @@ def do_invoke(code, user) it_behaves_like 'authenticate_totp', GuestUser.new it_behaves_like 'authenticate_totp', EncryptedUser.new + + it 'keeps a legacy encrypted secret and replay protection across persistence' do + allow(Devise).to receive(:otp_secret_encryption_key).and_return('a' * 32) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :persisted_encrypted_users, force: :cascade do |table| + table.string :encrypted_otp_secret_key + table.string :encrypted_otp_secret_key_iv + table.string :encrypted_otp_secret_key_salt + table.timestamp :totp_timestamp + end + end + end + + user_class = Class.new(ActiveRecord::Base) do + self.table_name = 'persisted_encrypted_users' + include Devise::Models::TwoFactorAuthenticatable + has_one_time_password encrypted: true + end + user = user_class.create!( + encrypted_otp_secret_key: "qqtceBScHArOXNFRTZfNyDih+kzYDujh7emlkGi4V6A=\n", + encrypted_otp_secret_key_iv: "ezgScHq7FcShFtQ2WYPP2g==\n", + encrypted_otp_secret_key_salt: "_NemuOAzhuv7qvoPP3RVyBA==\n" + ) + secret = 'JBSWY3DPEHPK3PXP' + code = TotpHelper.new(secret, user.class.otp_length).totp_code + + expect(user.otp_secret_key).to eq(secret) + expect(user.authenticate_totp(code)).to eq(true) + user.save! + + user.reload + expect(user.totp_timestamp).to be_a(Time) + expect(user.authenticate_totp(code)).to eq(false) + end end describe '#send_two_factor_authentication_code' do @@ -137,8 +172,11 @@ def instance.send_two_factor_authentication_code(code) end it "returns uri with user's email" do - expect(instance.provisioning_uri). - to match(%r{otpauth://totp/houdini@example.com\?secret=\w{32}}) + uri = URI.parse(instance.provisioning_uri) + params = URI.decode_www_form(uri.query).to_h + + expect(URI.decode_www_form_component(uri.path)).to eq('/houdini@example.com') + expect(params['secret']).to match(/\w{32}/) end it 'returns uri with issuer option' do diff --git a/spec/lib/two_factor_authentication/routes_spec.rb b/spec/lib/two_factor_authentication/routes_spec.rb new file mode 100644 index 00000000..55571760 --- /dev/null +++ b/spec/lib/two_factor_authentication/routes_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' + +describe 'two-factor authentication routes', type: :routing do + it 'routes resend_code through the collection endpoint' do + expect(get: '/users/two_factor_authentication/resend_code').to route_to( + controller: 'devise/two_factor_authentication', + action: 'resend_code' + ) + end +end diff --git a/spec/rails_app/config/application.rb b/spec/rails_app/config/application.rb index 2d31d588..29ce84c2 100644 --- a/spec/rails_app/config/application.rb +++ b/spec/rails_app/config/application.rb @@ -3,7 +3,6 @@ require "active_record/railtie" require "action_controller/railtie" require "action_mailer/railtie" -require "sprockets/railtie" Bundler.require(*Rails.groups) require "two_factor_authentication" @@ -47,12 +46,6 @@ class Application < Rails::Application # like if you have constraints or database-specific column types # config.active_record.schema_format = :sql - # Enable the asset pipeline - config.assets.enabled = true - - # Version of your assets, change this if you want to expire all your assets - config.assets.version = '1.0' - config.action_mailer.default_url_options = { host: 'localhost:3000' } config.i18n.enforce_available_locales = false @@ -60,4 +53,3 @@ class Application < Rails::Application config.secret_key_base = 'secretvalue' end end - diff --git a/spec/rails_app/config/environments/development.rb b/spec/rails_app/config/environments/development.rb index 85f5319b..05c1c2f7 100644 --- a/spec/rails_app/config/environments/development.rb +++ b/spec/rails_app/config/environments/development.rb @@ -20,9 +20,4 @@ # Only use best-standards-support built into browsers config.action_dispatch.best_standards_support = :builtin - # Do not compress assets - config.assets.compress = false - - # Expands the lines which load the assets - config.assets.debug = true end diff --git a/spec/rails_app/config/environments/production.rb b/spec/rails_app/config/environments/production.rb index d674b165..900d79af 100644 --- a/spec/rails_app/config/environments/production.rb +++ b/spec/rails_app/config/environments/production.rb @@ -9,18 +9,6 @@ config.consider_all_requests_local = false config.action_controller.perform_caching = true - # Disable Rails's static asset server (Apache or nginx will already do this) - config.serve_static_assets = false - - # Compress JavaScripts and CSS - config.assets.compress = true - - # Don't fallback to assets pipeline if a precompiled asset is missed - config.assets.compile = false - - # Generate digests for assets URLs - config.assets.digest = true - # Defaults to nil and saved in location specified by config.assets.prefix # config.assets.manifest = YOUR_PATH diff --git a/spec/rails_app/config/initializers/inflections.rb b/spec/rails_app/config/initializers/inflections.rb index 5d8d9be2..1d5e5332 100644 --- a/spec/rails_app/config/initializers/inflections.rb +++ b/spec/rails_app/config/initializers/inflections.rb @@ -13,3 +13,7 @@ # ActiveSupport::Inflector.inflections do |inflect| # inflect.acronym 'RESTful' # end + +ActiveSupport::Inflector.inflections do |inflect| + inflect.acronym 'SMS' +end diff --git a/two_factor_authentication.gemspec b/two_factor_authentication.gemspec index a95b2ed8..cd3410d2 100644 --- a/two_factor_authentication.gemspec +++ b/two_factor_authentication.gemspec @@ -33,7 +33,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'bundler' s.add_development_dependency 'rake' s.add_development_dependency 'rspec-rails', '>= 3.0.1' - s.add_development_dependency 'capybara', '~> 2.5' + s.add_development_dependency 'capybara', '>= 2.5', '< 4' s.add_development_dependency 'pry' s.add_development_dependency 'timecop' end