diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 4b251d9684..7cd6c7506b 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,24 @@ +* Add support for finding records based on signed ids, which are tamper-proof, verified ids that can be + set to expire and scoped with a purpose. This is particularly useful for things like password reset + or email verification, where you want the bearer of the signed id to be able to interact with the + underlying record, but usually only within a certain time period. + + ```ruby + signed_id = User.first.signed_id expires_in: 15.minutes, purpose: :password_reset + + User.find_signed signed_id # => nil, since the purpose does not match + + travel 16.minutes + User.find_signed signed_id, purpose: :password_reset # => nil, since the signed id has expired + + travel_back + User.find_signed signed_id, purpose: :password_reset # => User.first + + User.find_signed! "bad data" # => ActiveSupport::MessageVerifier::InvalidSignature + ``` + + *DHH* + * Support `ALGORITHM = INSTANT` DDL option for index operations on MySQL. *Ryuta Kamizono* diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 34d1eb344d..3442ada6ad 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -69,6 +69,7 @@ module ActiveRecord autoload :Serialization autoload :StatementCache autoload :Store + autoload :SignedId autoload :Suppressor autoload :Timestamp autoload :Transactions diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index c79f7bf455..f09437b51a 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -309,6 +309,7 @@ module ActiveRecord #:nodoc: include Serialization include Store include SecureToken + include SignedId include Suppressor end diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index cef94e3d1c..4dd4fbcfea 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -263,5 +263,11 @@ To keep using the current cache store, you can turn off cache versioning entirel self.filter_attributes += Rails.application.config.filter_parameters end end + + initializer "active_record.set_signed_id_verifier_secret" do + ActiveSupport.on_load(:active_record) do + self.signed_id_verifier_secret ||= Rails.application.key_generator.generate_key("active_record/signed_id") + end + end end end diff --git a/activerecord/lib/active_record/signed_id.rb b/activerecord/lib/active_record/signed_id.rb new file mode 100644 index 0000000000..a553e3d2f4 --- /dev/null +++ b/activerecord/lib/active_record/signed_id.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module ActiveRecord + # = Active Record Signed Id + module SignedId + extend ActiveSupport::Concern + + included do + ## + # :singleton-method: + # Set the secret used for the signed id verifier instance when using Active Record outside of Rails. + # Within Rails, this is automatically set using the Rails application key generator. + mattr_accessor :signed_id_verifier_secret, instance_writer: false + end + + module ClassMethods + # Lets you find a record based on a signed id that's safe to put into the world without risk of tampering. + # This is particularly useful for things like password reset or email verification, where you want + # the bearer of the signed id to be able to interact with the underlying record, but usually only within + # a certain time period. + # + # You set the time period that the signed id is valid for during generation, using the instance method + # +signed_id(expires_in: 15.minutes)+. If the time has elapsed before a signed find is attempted, + # the signed id will no longer be valid, and nil is returned. + # + # It's possible to further restrict the use of a signed id with a purpose. This helps when you have a + # general base model, like a User, which might have signed ids for several things, like password reset + # or email verification. The purpose that was set during generation must match the purpose set when + # finding. If there's a mismatch, nil is again returned. + # + # ==== Examples + # + # signed_id = User.first.signed_id expires_in: 15.minutes, purpose: :password_reset + # + # User.find_signed signed_id # => nil, since the purpose does not match + # + # travel 16.minutes + # User.find_signed signed_id, purpose: :password_reset # => nil, since the signed id has expired + # + # travel_back + # User.find_signed signed_id, purpose: :password_reset # => User.first + def find_signed(signed_id, purpose: nil) + if id = signed_id_verifier.verified(signed_id, purpose: combine_signed_id_purposes(purpose)) + find_by id: id + end + end + + # Works like +find_signed+, but will raise a +ActiveSupport::MessageVerifier::InvalidSignature+ + # exception if the +signed_id+ has either expired, has a purpose mismatch, is for another record, + # or has been tampered with. It will also raise a +ActiveRecord::RecordNotFound+ exception if + # the valid signed id can't find a record. + # + # === Examples + # + # User.find_signed! "bad data" # => ActiveSupport::MessageVerifier::InvalidSignature + # + # signed_id = User.first.signed_id + # User.first.destroy + # User.find_signed! signed_id # => ActiveRecord::RecordNotFound + def find_signed!(signed_id, purpose: nil) + if id = signed_id_verifier.verify(signed_id, purpose: combine_signed_id_purposes(purpose)) + find(id) + end + end + + # The verifier instance that all signed ids are generated and verified from. By default, it'll be initialized + # with the class-level +signed_id_verifier_secret+, which within Rails comes from the + # Rails.application.key_generator. By default, it's SHA256 for the digest and JSON for the serialization. + def signed_id_verifier + @signed_id_verifier ||= begin + if signed_id_verifier_secret.nil? + raise ArgumentError, "You must set ActiveRecord::Base.signed_id_verifier_secret to use signed ids" + else + ActiveSupport::MessageVerifier.new signed_id_verifier_secret, digest: "SHA256", serializer: JSON + end + end + end + + # Allows you to pass in a custom verifier used for the signed ids. This also allows you to use different + # verifiers for different classes. This is also helpful if you need to rotate keys, as you can prepare + # your custom verifier for that in advance. See +ActiveSupport::MessageVerifier+ for details. + def signed_id_verifier=(verifier) + @signed_id_verifier = verifier + end + + # :nodoc: + def combine_signed_id_purposes(purpose) + [ name.underscore, purpose.to_s ].compact_blank.join("/") + end + end + + + # Returns a signed id that's generated using a preconfigured +ActiveSupport::MessageVerifier+ instance. + # This signed id is tamper proof, so it's safe to send in an email or otherwise share with the outside world. + # It can further more be set to expire (the default is not to expire), and scoped down with a specific purpose. + # If the expiration date has been exceeded before +find_signed+ is called, the id won't find the designated + # record. If a purpose is set, this too must match. + # + # If you accidentally let a signed id out in the wild that you wish to retract sooner than its expiration date + # (or maybe you forgot to set an expiration date while meaning to!), you can use the purpose to essentially + # version the signed_id, like so: + # + # user.signed_id purpose: :v2 + # + # And you then change your +find_signed+ calls to require this new purpose. Any old signed ids that were not + # created with the purpose will no longer find the record. + def signed_id(expires_in: nil, purpose: nil) + self.class.signed_id_verifier.generate id, expires_in: expires_in, purpose: self.class.combine_signed_id_purposes(purpose) + end + end +end diff --git a/activerecord/test/cases/signed_id_test.rb b/activerecord/test/cases/signed_id_test.rb new file mode 100644 index 0000000000..0b963220c0 --- /dev/null +++ b/activerecord/test/cases/signed_id_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/account" +require "models/company" + +SIGNED_ID_VERIFIER_TEST_SECRET = "This is normally set by the railtie initializer when used with Rails!" + +ActiveRecord::Base.signed_id_verifier_secret = SIGNED_ID_VERIFIER_TEST_SECRET + +class SignedIdTest < ActiveRecord::TestCase + fixtures :accounts + + setup { @account = Account.first } + + test "find signed record" do + assert_equal @account, Account.find_signed(@account.signed_id) + end + + test "find signed record with a bang" do + assert_equal @account, Account.find_signed!(@account.signed_id) + end + + test "fail to find record from broken signed id" do + assert_nil Account.find_signed("this won't find anything") + end + + test "find signed record within expiration date" do + assert_equal @account, Account.find_signed(@account.signed_id(expires_in: 1.minute)) + end + + test "fail to find signed record within expiration date" do + signed_id = @account.signed_id(expires_in: 1.minute) + travel 2.minutes + assert_nil Account.find_signed(signed_id) + end + + test "fail to find record from that has since been destroyed" do + signed_id = @account.signed_id(expires_in: 1.minute) + @account.destroy + assert_nil Account.find_signed signed_id + end + + test "finding record from broken signed id raises on the bang" do + assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do + Account.find_signed! "this will blow up" + end + end + + test "finding signed record outside expiration date raises on the bang" do + signed_id = @account.signed_id(expires_in: 1.minute) + travel 2.minutes + + assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do + Account.find_signed!(signed_id) + end + end + + test "finding signed record that has been destroyed raises on the bang" do + signed_id = @account.signed_id(expires_in: 1.minute) + @account.destroy + + assert_raises(ActiveRecord::RecordNotFound) do + Account.find_signed!(signed_id) + end + end + + test "fail to work without a signed_id_verifier_secret" do + ActiveRecord::Base.signed_id_verifier_secret = nil + Account.instance_variable_set :@signed_id_verifier, nil + + assert_raises(ArgumentError) do + @account.signed_id + end + ensure + ActiveRecord::Base.signed_id_verifier_secret = SIGNED_ID_VERIFIER_TEST_SECRET + end + + test "use a custom verifier" do + old_verifier = Account.signed_id_verifier + Account.signed_id_verifier = ActiveSupport::MessageVerifier.new("sekret") + assert_not_equal ActiveRecord::Base.signed_id_verifier, Account.signed_id_verifier + assert_equal @account, Account.find_signed(@account.signed_id) + ensure + Account.signed_id_verifier = old_verifier + end +end