Skip to content

Commit e143d25

Browse files
authored
Add a default password reset token to has_secure_password (rails#52483)
* Add a default password reset token to has_secure_password * I hate this * Assist debugging * Add CHANGELOG entry
1 parent d7fcb97 commit e143d25

File tree

4 files changed

+99
-3
lines changed

4 files changed

+99
-3
lines changed

activemodel/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
* Add a default token generator for password reset tokens when using `has_secure_password`.
2+
3+
```ruby
4+
class User < ApplicationRecord
5+
has_secure_password
6+
end
7+
8+
user = User.create!(name: "david", password: "123", password_confirmation: "123")
9+
token = user.password_reset_token
10+
User.find_by_password_reset_token(token) # returns user
11+
12+
# 16 minutes later...
13+
User.find_by_password_reset_token(token) # returns nil
14+
15+
# raises ActiveSupport::MessageVerifier::InvalidSignature since the token is expired
16+
User.find_by_password_reset_token!(token)
17+
```
18+
19+
*DHH*
20+
121
* Add a load hook `active_model_translation` for `ActiveModel::Translation`.
222

323
*Shouichi Kamiya*

activemodel/lib/active_model/secure_password.rb

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ module ClassMethods
3939
# <tt>validations: false</tt> as an argument. This allows complete
4040
# customizability of validation behavior.
4141
#
42+
# Finally, a password reset token that's valid for 15 minutes after issue
43+
# is automatically configured when +reset_token+ is set to true (which it is by default)
44+
# and the object reponds to +generates_token_for+ (which Active Records do).
45+
#
4246
# To use +has_secure_password+, add bcrypt (~> 3.1.7) to your Gemfile:
4347
#
4448
# gem "bcrypt", "~> 3.1.7"
@@ -98,7 +102,18 @@ module ClassMethods
98102
# account.is_guest = true
99103
# account.valid? # => true
100104
#
101-
def has_secure_password(attribute = :password, validations: true)
105+
# ===== Using the password reset token
106+
#
107+
# user = User.create!(name: "david", password: "123", password_confirmation: "123")
108+
# token = user.password_reset_token
109+
# User.find_by_password_reset_token(token) # returns user
110+
#
111+
# # 16 minutes later...
112+
# User.find_by_password_reset_token(token) # returns nil
113+
#
114+
# # raises ActiveSupport::MessageVerifier::InvalidSignature since the token is expired
115+
# User.find_by_password_reset_token!(token)
116+
def has_secure_password(attribute = :password, validations: true, reset_token: true)
102117
# Load bcrypt gem only when has_secure_password is used.
103118
# This is to avoid ActiveModel (and by extension the entire framework)
104119
# being dependent on a binary library.
@@ -109,7 +124,7 @@ def has_secure_password(attribute = :password, validations: true)
109124
raise
110125
end
111126

112-
include InstanceMethodsOnActivation.new(attribute)
127+
include InstanceMethodsOnActivation.new(attribute, reset_token: reset_token)
113128

114129
if validations
115130
include ActiveModel::Validations
@@ -142,11 +157,30 @@ def has_secure_password(attribute = :password, validations: true)
142157

143158
validates_confirmation_of attribute, allow_blank: true
144159
end
160+
161+
# Only generate tokens for records that are capable of doing so (Active Records, not vanilla Active Models)
162+
if reset_token && respond_to?(:generates_token_for)
163+
generates_token_for :"#{attribute}_reset", expires_in: 15.minutes do
164+
public_send(:"#{attribute}_salt")&.last(10)
165+
end
166+
167+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
168+
silence_redefinition_of_method :find_by_#{attribute}_reset_token
169+
def self.find_by_#{attribute}_reset_token(token)
170+
find_by_token_for("#{attribute}_reset", token)
171+
end
172+
173+
silence_redefinition_of_method :find_by_#{attribute}_reset_token!
174+
def self.find_by_#{attribute}_reset_token!(token)
175+
find_by_token_for!("#{attribute}_reset", token)
176+
end
177+
RUBY
178+
end
145179
end
146180
end
147181

148182
class InstanceMethodsOnActivation < Module
149-
def initialize(attribute)
183+
def initialize(attribute, reset_token:)
150184
attr_reader attribute
151185

152186
define_method("#{attribute}=") do |unencrypted_password|
@@ -184,6 +218,13 @@ def initialize(attribute)
184218
end
185219

186220
alias_method :authenticate, :authenticate_password if attribute == :password
221+
222+
if reset_token
223+
# Returns the class-level configured reset token for the password.
224+
define_method("#{attribute}_reset_token") do
225+
generate_token_for("#{attribute}_reset")
226+
end
227+
end
187228
end
188229
end
189230
end

activemodel/test/cases/secure_password_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "cases/helper"
44
require "models/user"
5+
require "models/pilot"
56
require "models/visitor"
67

78
class SecurePasswordTest < ActiveModel::TestCase
@@ -12,6 +13,7 @@ class SecurePasswordTest < ActiveModel::TestCase
1213

1314
@user = User.new
1415
@visitor = Visitor.new
16+
@pilot = Pilot.new
1517

1618
# Simulate loading an existing user from the DB
1719
@existing_user = User.new
@@ -314,4 +316,12 @@ class SecurePasswordTest < ActiveModel::TestCase
314316
@user.password = "secret"
315317
assert_equal BCrypt::Engine::MIN_COST, @user.password_digest.cost
316318
end
319+
320+
test "password reset token" do
321+
assert_not @person.respond_to? :password_reset_token
322+
assert_equal "password_reset-token-900", @pilot.password_reset_token
323+
324+
assert_equal "finding-for-password_reset-by-999", Pilot.find_by_password_reset_token("999")
325+
assert_equal "finding-for-password_reset-by-999!", Pilot.find_by_password_reset_token!("999")
326+
end
317327
end

activemodel/test/models/pilot.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
class Pilot
4+
include ActiveModel::Attributes
5+
include ActiveModel::SecurePassword
6+
7+
def self.generates_token_for(purpose, expires_in: nil)
8+
@@expires_in = expires_in
9+
end
10+
11+
def self.find_by_token_for(purpose, token)
12+
"finding-for-#{purpose}-by-#{token}"
13+
end
14+
15+
def self.find_by_token_for!(purpose, token)
16+
"finding-for-#{purpose}-by-#{token}!"
17+
end
18+
19+
def generate_token_for(purpose)
20+
"#{purpose}-token-#{@@expires_in}"
21+
end
22+
23+
attribute :password_digest
24+
has_secure_password
25+
end

0 commit comments

Comments
 (0)