/
signed_id.rb
118 lines (106 loc) · 5.65 KB
/
signed_id.rb
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
# 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 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.
# 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