Skip to content

Commit be400c3

Browse files
Add ActiveRecord::Base::generates_token_for (rails#44189)
Currently, `signed_id` fulfills the role of generating tokens for e.g. resetting a password. However, signed IDs cannot reflect record state, so if a token is intended to be single-use, it must be tracked in a database at least until it expires. With `generates_token_for`, a token can embed data from a record. When using the token to fetch the record, the data from the token and the data from the record will be compared. If the two do not match, the token will be treated as invalid, the same as if it had expired. For example: ```ruby class User < ActiveRecord::Base has_secure_password generates_token_for :password_reset do # BCrypt salt changes when password is updated BCrypt::Password.new(password_digest).salt[-10..] end end user = User.first token = user.generate_token_for(:password_reset) User.find_by_token_for(:password_reset, token) # => user user.update!(password: "new password") User.find_by_token_for(:password_reset, token) # => nil ```
1 parent 9a7d442 commit be400c3

File tree

6 files changed

+281
-0
lines changed

6 files changed

+281
-0
lines changed

activerecord/lib/active_record.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ module ActiveRecord
7777
autoload :TestDatabases
7878
autoload :TestFixtures, "active_record/fixtures"
7979
autoload :Timestamp
80+
autoload :TokenFor
8081
autoload :TouchLater
8182
autoload :Transactions
8283
autoload :Translation

activerecord/lib/active_record/base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ class Base
325325
include Serialization
326326
include Store
327327
include SecureToken
328+
include TokenFor
328329
include SignedId
329330
include Suppressor
330331
include Encryption::EncryptableRecord

activerecord/lib/active_record/railtie.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ class Railtie < Rails::Railtie # :nodoc:
334334
end
335335
end
336336

337+
initializer "active_record.generated_token_verifier" do
338+
config.after_initialize do |app|
339+
ActiveSupport.on_load(:active_record) do
340+
self.generated_token_verifier ||= app.message_verifier("active_record/token_for")
341+
end
342+
end
343+
end
344+
337345
initializer "active_record_encryption.configuration" do |app|
338346
ActiveRecord::Encryption.configure \
339347
primary_key: app.credentials.dig(:active_record_encryption, :primary_key),
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/core_ext/object/json"
4+
5+
module ActiveRecord
6+
module TokenFor
7+
extend ActiveSupport::Concern
8+
9+
included do
10+
class_attribute :token_definitions, instance_accessor: false, instance_predicate: false, default: {}
11+
class_attribute :generated_token_verifier, instance_accessor: false, instance_predicate: false
12+
end
13+
14+
# :nodoc:
15+
TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do
16+
def full_purpose
17+
@full_purpose ||= [defining_class.name, purpose, expires_in].join("\n")
18+
end
19+
20+
def message_verifier
21+
defining_class.generated_token_verifier
22+
end
23+
24+
def payload_for(model)
25+
block ? [model.id, model.instance_eval(&block).as_json] : [model.id]
26+
end
27+
28+
def generate_token(model)
29+
message_verifier.generate(payload_for(model), expires_in: expires_in, purpose: full_purpose)
30+
end
31+
32+
def resolve_token(token)
33+
payload = message_verifier.verified(token, purpose: full_purpose)
34+
model = yield(payload[0]) if payload
35+
model if model && payload_for(model) == payload
36+
end
37+
end
38+
39+
module ClassMethods
40+
# Defines the behavior of tokens generated for a specific +purpose+.
41+
# A token can be generated by calling TokenFor#generate_token_for on a
42+
# record. Later, that record can be fetched by calling #find_by_token_for
43+
# (or #find_by_token_for!) with the same purpose and token.
44+
#
45+
# Tokens are signed so that they are tamper-proof. Thus they can be
46+
# exposed to outside world as, for example, password reset tokens.
47+
#
48+
# By default, tokens do not expire. They can be configured to expire by
49+
# specifying a duration via the +expires_in+ option. The duration becomes
50+
# part of the token's signature, so changing the value of +expires_in+
51+
# will automatically invalidate previously generated tokens.
52+
#
53+
# A block may also be specified. When generating a token with
54+
# TokenFor#generate_token_for, the block will be evaluated in the context
55+
# of the record, and its return value will be embedded in the token as
56+
# JSON. Later, when fetching the record with #find_by_token_for, the block
57+
# will be evaluated again in the context of the fetched record. If the two
58+
# JSON values do not match, the token will be treated as invalid. Note
59+
# that the value returned by the block <strong>should not contain
60+
# sensitive information</strong> because it will be embedded in the token
61+
# as <strong>human-readable plaintext JSON</strong>.
62+
#
63+
# ==== Examples
64+
#
65+
# class User < ActiveRecord::Base
66+
# has_secure_password
67+
#
68+
# generates_token_for :password_reset, expires_in: 15.minutes do
69+
# # Last 10 characters of password salt, which changes when password is updated:
70+
# BCrypt::Password.new(password_digest).salt[-10..]
71+
# end
72+
# end
73+
#
74+
# user = User.first
75+
#
76+
# token = user.generate_token_for(:password_reset)
77+
# User.find_by_token_for(:password_reset, token) # => user
78+
# # 16 minutes later...
79+
# User.find_by_token_for(:password_reset, token) # => nil
80+
#
81+
# token = user.generate_token_for(:password_reset)
82+
# User.find_by_token_for(:password_reset, token) # => user
83+
# user.update!(password: "new password")
84+
# User.find_by_token_for(:password_reset, token) # => nil
85+
def generates_token_for(purpose, expires_in: nil, &block)
86+
self.token_definitions = token_definitions.merge(purpose => TokenDefinition.new(self, purpose, expires_in, block))
87+
end
88+
89+
# Finds a record using a given +token+ for a predefined +purpose+. Returns
90+
# +nil+ if the token is invalid or the record was not found.
91+
def find_by_token_for(purpose, token)
92+
raise UnknownPrimaryKey.new(self) unless primary_key
93+
token_definitions.fetch(purpose).resolve_token(token) { |id| find_by(primary_key => id) }
94+
end
95+
96+
# Finds a record using a given +token+ for a predefined +purpose+. Raises
97+
# ActiveSupport::MessageVerifier::InvalidSignature if the token is invalid
98+
# (e.g. expired, bad format, etc). Raises ActiveRecord::RecordNotFound if
99+
# the token is valid but the record was not found.
100+
def find_by_token_for!(purpose, token)
101+
token_definitions.fetch(purpose).resolve_token(token) { |id| find(id) } ||
102+
(raise ActiveSupport::MessageVerifier::InvalidSignature)
103+
end
104+
end
105+
106+
# Generates a token for a predefined +purpose+.
107+
#
108+
# Use ClassMethods::generates_token_for to define a token purpose and
109+
# behavior.
110+
def generate_token_for(purpose)
111+
self.class.token_definitions.fetch(purpose).generate_token(self)
112+
end
113+
end
114+
end
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/matey"
5+
require "models/user"
6+
require "active_support/message_verifier"
7+
8+
class TokenForTest < ActiveRecord::TestCase
9+
class User < ::User
10+
generates_token_for :lookup
11+
12+
generates_token_for :password_reset, expires_in: 15.minutes do
13+
password_digest.to_s[-(31 + 22), 10] # first 10 characters of BCrypt salt
14+
end
15+
16+
generates_token_for :snapshot do
17+
{ updated_at: updated_at }
18+
end
19+
end
20+
21+
setup do
22+
@original_verifier = ActiveRecord::Base.generated_token_verifier
23+
ActiveRecord::Base.generated_token_verifier = ActiveSupport::MessageVerifier.new("secret")
24+
25+
@user = User.create!(password_digest: "$2a$4$#{"x" * 22}#{"y" * 31}")
26+
@lookup_token = @user.generate_token_for(:lookup)
27+
@password_reset_token = @user.generate_token_for(:password_reset)
28+
end
29+
30+
teardown do
31+
ActiveRecord::Base.generated_token_verifier = @original_verifier
32+
end
33+
34+
test "finds record by token" do
35+
assert_equal @user, User.find_by_token_for(:lookup, @lookup_token)
36+
assert_equal @user, User.find_by_token_for!(:lookup, @lookup_token)
37+
end
38+
39+
test "returns nil when record is not found" do
40+
@user.destroy
41+
assert_nil User.find_by_token_for(:lookup, @lookup_token)
42+
end
43+
44+
test "raises on bang when record is not found" do
45+
@user.destroy
46+
assert_raises(ActiveRecord::RecordNotFound) do
47+
User.find_by_token_for!(:lookup, @lookup_token)
48+
end
49+
end
50+
51+
test "raises when token definition does not exist" do
52+
assert_raises { User.find_by_token_for(:bad, @lookup_token) }
53+
end
54+
55+
test "does not find record when token is invalid" do
56+
assert_nil User.find_by_token_for(:lookup, "bad")
57+
assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do
58+
User.find_by_token_for!(:lookup, "bad")
59+
end
60+
end
61+
62+
test "does not find record when token is for a different purpose" do
63+
assert_nil User.find_by_token_for(:password_reset, @lookup_token)
64+
assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do
65+
User.find_by_token_for!(:password_reset, @lookup_token)
66+
end
67+
end
68+
69+
test "finds record when token has not expired and embedded data has not changed" do
70+
assert_equal @user, User.find_by_token_for(:password_reset, @password_reset_token)
71+
end
72+
73+
test "does not find record when token has expired" do
74+
travel 1.day
75+
assert_nil User.find_by_token_for(:password_reset, @password_reset_token)
76+
assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do
77+
User.find_by_token_for!(:password_reset, @password_reset_token)
78+
end
79+
end
80+
81+
test "tokens do not expire by default" do
82+
travel 1000.years
83+
assert_equal @user, User.find_by_token_for(:lookup, @lookup_token)
84+
end
85+
86+
test "does not find record when expires_in is different" do
87+
User.generates_token_for :lookup, expires_in: 1.year
88+
89+
assert_nil User.find_by_token_for(:lookup, @lookup_token)
90+
new_lookup_token = @user.generate_token_for(:lookup)
91+
assert_equal @user, User.find_by_token_for(:lookup, new_lookup_token)
92+
ensure
93+
User.generates_token_for :lookup
94+
end
95+
96+
test "does not find record when embedded data is different" do
97+
@user.update!(password: "new password")
98+
assert_nil User.find_by_token_for(:password_reset, @password_reset_token)
99+
assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do
100+
User.find_by_token_for!(:password_reset, @password_reset_token)
101+
end
102+
end
103+
104+
test "supports JSON-serializable embedded data" do
105+
snapshot_token = @user.generate_token_for(:snapshot)
106+
assert_equal @user, User.find_by_token_for(:snapshot, snapshot_token)
107+
@user.touch
108+
assert_nil User.find_by_token_for(:snapshot, snapshot_token)
109+
end
110+
111+
test "finds record through relation" do
112+
assert_equal @user, User.where("1=1").find_by_token_for(:lookup, @lookup_token)
113+
assert_nil User.where("1=0").find_by_token_for(:lookup, @lookup_token)
114+
end
115+
116+
test "finds record through subclass" do
117+
subclass = Class.new(User)
118+
subclassed_user = subclass.find_by_token_for(:lookup, @lookup_token)
119+
120+
assert_instance_of subclass, subclassed_user
121+
assert_equal @user.id, subclassed_user.id
122+
end
123+
124+
test "subclasses can redefine tokens" do
125+
subclass = Class.new(User) do
126+
generates_token_for :lookup
127+
end
128+
subclassed_user = subclass.find(@user.id)
129+
subclassed_lookup_token = subclassed_user.generate_token_for(:lookup)
130+
131+
assert_equal subclassed_user, subclass.find_by_token_for(:lookup, subclassed_lookup_token)
132+
assert_nil subclass.find_by_token_for(:lookup, @lookup_token)
133+
assert_nil User.find_by_token_for(:lookup, subclassed_lookup_token)
134+
end
135+
136+
test "finds record with a custom primary key" do
137+
custom_pk = Class.new(User) do
138+
self.primary_key = "auth_token"
139+
end
140+
custom_pk_user = custom_pk.find(@user.auth_token)
141+
custom_pk_lookup_token = custom_pk_user.generate_token_for(:lookup)
142+
143+
assert_equal custom_pk_user, custom_pk.find_by_token_for(:lookup, custom_pk_lookup_token)
144+
assert_nil custom_pk.find_by_token_for(:lookup, @lookup_token)
145+
end
146+
147+
test "raises when no primary key has been declared" do
148+
no_pk = Class.new(Matey) do
149+
generates_token_for :parley
150+
end
151+
152+
assert_raises(ActiveRecord::UnknownPrimaryKey) do
153+
no_pk.find_by_token_for(:parley, "this token will not be checked")
154+
end
155+
end
156+
end

activerecord/test/schema/schema.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,7 @@
12911291
t.string :auth_token
12921292
t.string :password_digest
12931293
t.string :recovery_password_digest
1294+
t.timestamps null: true
12941295
end
12951296

12961297
create_table :test_with_keyword_column_name, force: true do |t|

0 commit comments

Comments
 (0)