/
token_for.rb
114 lines (102 loc) · 4.77 KB
/
token_for.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
# frozen_string_literal: true
require "active_support/core_ext/object/json"
module ActiveRecord
module TokenFor
extend ActiveSupport::Concern
included do
class_attribute :token_definitions, instance_accessor: false, instance_predicate: false, default: {}
class_attribute :generated_token_verifier, instance_accessor: false, instance_predicate: false
end
# :nodoc:
TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do
def full_purpose
@full_purpose ||= [defining_class.name, purpose, expires_in].join("\n")
end
def message_verifier
defining_class.generated_token_verifier
end
def payload_for(model)
block ? [model.id, model.instance_eval(&block).as_json] : [model.id]
end
def generate_token(model)
message_verifier.generate(payload_for(model), expires_in: expires_in, purpose: full_purpose)
end
def resolve_token(token)
payload = message_verifier.verified(token, purpose: full_purpose)
model = yield(payload[0]) if payload
model if model && payload_for(model) == payload
end
end
module ClassMethods
# Defines the behavior of tokens generated for a specific +purpose+.
# A token can be generated by calling TokenFor#generate_token_for on a
# record. Later, that record can be fetched by calling #find_by_token_for
# (or #find_by_token_for!) with the same purpose and token.
#
# Tokens are signed so that they are tamper-proof. Thus they can be
# exposed to outside world as, for example, password reset tokens.
#
# By default, tokens do not expire. They can be configured to expire by
# specifying a duration via the +expires_in+ option. The duration becomes
# part of the token's signature, so changing the value of +expires_in+
# will automatically invalidate previously generated tokens.
#
# A block may also be specified. When generating a token with
# TokenFor#generate_token_for, the block will be evaluated in the context
# of the record, and its return value will be embedded in the token as
# JSON. Later, when fetching the record with #find_by_token_for, the block
# will be evaluated again in the context of the fetched record. If the two
# JSON values do not match, the token will be treated as invalid. Note
# that the value returned by the block <strong>should not contain
# sensitive information</strong> because it will be embedded in the token
# as <strong>human-readable plaintext JSON</strong>.
#
# ==== Examples
#
# class User < ActiveRecord::Base
# has_secure_password
#
# generates_token_for :password_reset, expires_in: 15.minutes do
# # Last 10 characters of password salt, which 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
# # 16 minutes later...
# User.find_by_token_for(:password_reset, token) # => nil
#
# 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
def generates_token_for(purpose, expires_in: nil, &block)
self.token_definitions = token_definitions.merge(purpose => TokenDefinition.new(self, purpose, expires_in, block))
end
# Finds a record using a given +token+ for a predefined +purpose+. Returns
# +nil+ if the token is invalid or the record was not found.
def find_by_token_for(purpose, token)
raise UnknownPrimaryKey.new(self) unless primary_key
token_definitions.fetch(purpose).resolve_token(token) { |id| find_by(primary_key => id) }
end
# Finds a record using a given +token+ for a predefined +purpose+. Raises
# ActiveSupport::MessageVerifier::InvalidSignature if the token is invalid
# (e.g. expired, bad format, etc). Raises ActiveRecord::RecordNotFound if
# the token is valid but the record was not found.
def find_by_token_for!(purpose, token)
token_definitions.fetch(purpose).resolve_token(token) { |id| find(id) } ||
(raise ActiveSupport::MessageVerifier::InvalidSignature)
end
end
# Generates a token for a predefined +purpose+.
#
# Use ClassMethods::generates_token_for to define a token purpose and
# behavior.
def generate_token_for(purpose)
self.class.token_definitions.fetch(purpose).generate_token(self)
end
end
end