From 1a3dc42c172863fc2db24e9813203d2e793a9e2a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 17 May 2020 11:19:37 -0700 Subject: [PATCH] Add signed ids to Active Record (#39313) 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. --- activerecord/CHANGELOG.md | 21 ++++ activerecord/lib/active_record.rb | 1 + activerecord/lib/active_record/base.rb | 1 + activerecord/lib/active_record/railtie.rb | 6 ++ activerecord/lib/active_record/signed_id.rb | 111 ++++++++++++++++++++ activerecord/test/cases/signed_id_test.rb | 87 +++++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 activerecord/lib/active_record/signed_id.rb create mode 100644 activerecord/test/cases/signed_id_test.rb 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