1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
|
# 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.
class_attribute :signed_id_verifier_secret, instance_writer: false
end
module RelationMethods # :nodoc:
def find_signed(...)
scoping { model.find_signed(...) }
end
def find_signed!(...)
scoping { model.find_signed!(...) }
end
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
# <tt>signed_id(expires_in: 15.minutes)</tt>. 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)
raise UnknownPrimaryKey.new(self) if primary_key.nil?
if id = signed_id_verifier.verified(signed_id, purpose: combine_signed_id_purposes(purpose))
find_by primary_key => id
end
end
# Works like find_signed, but will raise an +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 an +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
secret = signed_id_verifier_secret
secret = secret.call if secret.respond_to?(:call)
if secret.nil?
raise ArgumentError, "You must set ActiveRecord::Base.signed_id_verifier_secret to use signed ids"
else
ActiveSupport::MessageVerifier.new secret, digest: "SHA256", serializer: JSON, url_safe: true
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)
[ base_class.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.
# However, as with any message signed with a +ActiveSupport::MessageVerifier+,
# {the signed id is not encrypted}[link:classes/ActiveSupport/MessageVerifier.html#class-ActiveSupport::MessageVerifier-label-Signing+is+not+encryption].
# It's just encoded and protected against tampering.
#
# This means that the ID can be decoded by anyone; however, if tampered with (so to point to a different ID),
# the cryptographic signature will no longer match, and the signed id will be considered invalid and return nil
# when passed to +find_signed+ (or raise with +find_signed!+).
#
# It can furthermore 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, expires_at: nil, purpose: nil)
raise ArgumentError, "Cannot get a signed_id for a new record" if new_record?
self.class.signed_id_verifier.generate id, expires_in: expires_in, expires_at: expires_at, purpose: self.class.combine_signed_id_purposes(purpose)
end
end
end
|