Skip to content

Commit fa01a54

Browse files
Use signed_id to reset password.
This is more secure, and also requires fewer columns. Issues ------ - Closes #59
1 parent 37f16b5 commit fa01a54

File tree

12 files changed

+81
-130
lines changed

12 files changed

+81
-130
lines changed

README.md

Lines changed: 28 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -604,70 +604,37 @@ class UsersController < ApplicationController
604604
end
605605
```
606606

607-
## Step 9: Add Password Reset Columns to Users Table
607+
## Step 9: Add Password Reset Functionality
608608

609-
1. Create migration.
610-
611-
```bash
612-
rails g migration add_password_reset_token_to_users password_reset_token:string password_reset_sent_at:datetime
613-
```
614-
615-
2. Update the migration.
616-
617-
```ruby
618-
# db/migrate/[timestamp]_add_password_reset_token_to_users.rb
619-
class AddPasswordResetTokenToUsers < ActiveRecord::Migration[6.1]
620-
def change
621-
add_column :users, :password_reset_token, :string, null: false
622-
add_column :users, :password_reset_sent_at, :datetime
623-
add_index :users, :password_reset_token, unique: true
624-
end
625-
end
626-
```
627-
628-
> **What's Going On Here?**
629-
>
630-
> - The `password_reset_token` column will store a random value created through the [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) method when a record is saved. This will be used to identify users in a secure way when they need to reset their password. We add `null: false` to prevent empty values and also add a unique index to ensure that no two users will have the same `password_reset_token`. You can think of this as a secure alternative to the `id` column.
631-
> - The `password_reset_sent_at` column will be used to ensure a password reset link has not expired. This is an added layer of security to prevent a `password_reset_token` from being used multiple times.
632-
633-
3. Run migration.
634-
635-
```bash
636-
rails db:migrate
637-
```
638-
639-
4. Update User Model.
609+
1. Update User Model.
640610

641611
```ruby
642612
# app/models/user.rb
643613
class User < ApplicationRecord
644614
...
645-
PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i
615+
PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes
646616
...
647-
has_secure_token :password_reset_token
648-
...
649-
def password_reset_token_has_expired?
650-
return true if password_reset_sent_at.nil?
651-
(Time.current - password_reset_sent_at) >= User::PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS
617+
def generate_password_reset_token
618+
signed_id expires_in: PASSWORD_RESET_TOKEN_EXPIRATION, purpose: :reset_password
652619
end
653-
620+
...
654621
def send_password_reset_email!
655-
regenerate_password_reset_token
656-
update_columns(password_reset_sent_at: Time.current)
657-
UserMailer.password_reset(self).deliver_now
622+
password_reset_token = generate_password_reset_token
623+
UserMailer.password_reset(self, password_reset_token).deliver_now
658624
end
659625
...
660626
end
661627
```
662628

663-
5. Update User Mailer.
629+
2. Update User Mailer.
664630

665631
```ruby
666632
# app/mailers/user_mailer.rb
667633
class UserMailer < ApplicationMailer
668634
...
669-
def password_reset(user)
635+
def password_reset(user, password_reset_token)
670636
@user = user
637+
@password_reset_token = password_reset_token
671638

672639
mail to: @user.email, subject: "Password Reset Instructions"
673640
end
@@ -678,21 +645,20 @@ end
678645
<!-- app/views/user_mailer/password_reset.html.erb -->
679646
<h1>Password Reset Instructions</h1>
680647
681-
<%= link_to "Click here to reset your password.", edit_password_url(@user.password_reset_token) %>
648+
<%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %>
682649
```
683650

684651
```text
685652
<!-- app/views/user_mailer/password_reset.text.erb -->
686653
Password Reset Instructions
687654
688-
<%= edit_password_url(@user.password_reset_token) %>
655+
<%= edit_password_url(@password_reset_token) %>
689656
```
690657

691658
> **What's Going On Here?**
692659
>
693-
> - The `has_secure_token :password_reset_token` method is added to give us an [API](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) to work with the `password_reset_token` column.
694-
> - The `password_reset_token_has_expired?` method tells us if the password reset token is expired or not. This can be controlled by changing the value of the `PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS` constant. This will be useful when we build the password reset mailer.
695-
> - The `send_password_reset_email!` method will create a new `password_reset_token` and update the value of `password_reset_sent_at`. This is to ensure password reset links expire and cannot be reused. It will also send the password reset email to the user. We still need to build this.
660+
> - The `generate_password_reset_token` method creates a [signed_id](https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id) that will be used to securely identify the user. For added security, we ensure that this ID will expire in 10 minutes (this can be controlled with the `PASSWORD_RESET_TOKEN_EXPIRATION` constant) and give it an explicit purpose of `:reset_password`.
661+
> - The `send_password_reset_email!` method will create a new `password_reset_token`. This is to ensure password reset links expire and cannot be reused. It will also send the password reset email to the user.
696662
697663
## Step 10: Build Password Reset Forms
698664

@@ -722,10 +688,10 @@ class PasswordsController < ApplicationController
722688
end
723689

724690
def edit
725-
@user = User.find_by(password_reset_token: params[:password_reset_token])
691+
@user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
726692
if @user.present? && @user.unconfirmed?
727693
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
728-
elsif @user.nil? || @user.password_reset_token_has_expired?
694+
elsif @user.nil?
729695
redirect_to new_password_path, alert: "Invalid or expired token."
730696
end
731697
end
@@ -734,21 +700,18 @@ class PasswordsController < ApplicationController
734700
end
735701

736702
def update
737-
@user = User.find_by(password_reset_token: params[:password_reset_token])
703+
@user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
738704
if @user
739705
if @user.unconfirmed?
740706
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
741-
elsif @user.password_reset_token_has_expired?
742-
redirect_to new_password_path, alert: "Incorrect email or password."
743707
elsif @user.update(password_params)
744-
@user.regenerate_password_reset_token
745-
redirect_to login_path, notice: "Signed in."
708+
redirect_to login_path, notice: "Sign in."
746709
else
747710
flash.now[:alert] = @user.errors.full_messages.to_sentence
748711
render :edit, status: :unprocessable_entity
749712
end
750713
else
751-
flash.now[:alert] = "Incorrect email or password."
714+
flash.now[:alert] = "Invalid or expired token."
752715
render :new, status: :unprocessable_entity
753716
end
754717
end
@@ -761,15 +724,18 @@ class PasswordsController < ApplicationController
761724
end
762725
```
763726

727+
> - The `edit` action is used to confirm a user's email. This will be the page that a user lands on when they click the confirmation link in their email. We still need to build this. Note that we're looking up a user through the [find_signed](https://api.rubyonrails.org/classes/ActiveRecord/SignedId/ClassMethods.html#method-i-find_signed) method and not their email or ID. This is because The `confirmation_token` is randomly generated and can't be guessed or tampered with unlike an email or numeric ID. This is also why we added `param: :confirmation_token` as a [named route parameter](https://guides.rubyonrails.org/routing.html#overriding-named-route-parameters).
728+
>
729+
764730
> **What's Going On Here?**
765731
>
766732
> - The `create` action will send an email to the user containing a link that will allow them to reset the password. The link will contain their `password_reset_token` which is unique and expires. Note that we call `downcase` on the email to account for case sensitivity when searching.
767-
> - Note that we return `If that user exists we've sent instructions to their email.` even if the user is not found. This makes it difficult for a bad actor to use the reset form to see which email accounts exist on the application.
768-
> - The `edit` action renders simply renders the form for the user to update their password. It attempts to find a user by their `password_reset_token`. You can think of the `password_reset_token` as a way to identify the user much like how we normally identify records by their ID. However, the `password_reset_token` is randomly generated and will expire so it's more secure.
733+
> - You'll remember that the `password_reset_token` is a [signed_id](https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id), and is set to expire in 10 minutes. You'll also note that we need to pass the method `purpose: :reset_password` to be consistent with the purpose that was set in the `generate_confirmation_token` method.
734+
> - Note that we return `Invalid or expired token.` if the user is not found. This makes it difficult for a bad actor to use the reset form to see which email accounts exist on the application.
735+
> - The `edit` action simply renders the form for the user to update their password. It attempts to find a user by their `password_reset_token`. You can think of the `password_reset_token` as a way to identify the user much like how we normally identify records by their ID. However, the `password_reset_token` is randomly generated and will expire so it's more secure.
769736
> - The `new` action simply renders a form for the user to put their email address in to receive the password reset email.
770737
> - The `update` also ensures the user is identified by their `password_reset_token`. It's not enough to just do this on the `edit` action since a bad actor could make a `PUT` request to the server and bypass the form.
771-
> - If the user exists and is confirmed and their password token has not expired, we update their password to the one they will set in the form. Otherwise, we handle each failure case differently.
772-
> - Note that we call `@user.regenerate_password_reset_token` to ensure their `password_reset_token` is reset so that it cannot be used again.
738+
> - If the user exists and is confirmed we update their password to the one they will set in the form. Otherwise, we handle each failure case differently.
773739
774740
2. Update Routes.
775741

@@ -797,7 +763,7 @@ end
797763

798764
```html+ruby
799765
<!-- app/views/passwords/edit.html.erb -->
800-
<%= form_with url: password_path(@user.password_reset_token), scope: :user, method: :put do |form| %>
766+
<%= form_with url: password_path(params[:password_reset_token]), scope: :user, method: :put do |form| %>
801767
<div>
802768
<%= form.label :password %>
803769
<%= form.password_field :password, required: true %>

app/controllers/passwords_controller.rb

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ def create
1616
end
1717

1818
def edit
19-
@user = User.find_by(password_reset_token: params[:password_reset_token])
19+
@user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
2020
if @user.present? && @user.unconfirmed?
2121
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
22-
elsif @user.nil? || @user.password_reset_token_has_expired?
22+
elsif @user.nil?
2323
redirect_to new_password_path, alert: "Invalid or expired token."
2424
end
2525
end
@@ -28,21 +28,18 @@ def new
2828
end
2929

3030
def update
31-
@user = User.find_by(password_reset_token: params[:password_reset_token])
31+
@user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
3232
if @user
3333
if @user.unconfirmed?
3434
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
35-
elsif @user.password_reset_token_has_expired?
36-
redirect_to new_password_path, alert: "Incorrect email or password."
3735
elsif @user.update(password_params)
38-
@user.regenerate_password_reset_token
39-
redirect_to login_path, notice: "Password updated."
36+
redirect_to login_path, notice: "Sign in."
4037
else
4138
flash.now[:alert] = @user.errors.full_messages.to_sentence
4239
render :edit, status: :unprocessable_entity
4340
end
4441
else
45-
flash.now[:alert] = "Incorrect email or password."
42+
flash.now[:alert] = "Invalid or expired token."
4643
render :new, status: :unprocessable_entity
4744
end
4845
end

app/mailers/user_mailer.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ def confirmation(user, confirmation_token)
1313
mail to: @user.confirmable_email, subject: "Confirmation Instructions"
1414
end
1515

16-
def password_reset(user)
16+
def password_reset(user, password_reset_token)
1717
@user = user
18+
@password_reset_token = password_reset_token
1819

1920
mail to: @user.email, subject: "Password Reset Instructions"
2021
end

app/models/user.rb

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
class User < ApplicationRecord
22
CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
33
MAILER_FROM_EMAIL = "no-reply@example.com"
4-
PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i
4+
PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes
55

66
attr_accessor :current_password
77

88
has_secure_password
9-
has_secure_token :password_reset_token
109
has_secure_token :remember_token
1110
has_secure_token :session_token
1211

@@ -58,9 +57,8 @@ def generate_confirmation_token
5857
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
5958
end
6059

61-
def password_reset_token_has_expired?
62-
return true if password_reset_sent_at.nil?
63-
(Time.current - password_reset_sent_at) >= User::PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS
60+
def generate_password_reset_token
61+
signed_id expires_in: PASSWORD_RESET_TOKEN_EXPIRATION, purpose: :reset_password
6462
end
6563

6664
def send_confirmation_email!
@@ -69,9 +67,8 @@ def send_confirmation_email!
6967
end
7068

7169
def send_password_reset_email!
72-
regenerate_password_reset_token
73-
update_columns(password_reset_sent_at: Time.current)
74-
UserMailer.password_reset(self).deliver_now
70+
password_reset_token = generate_password_reset_token
71+
UserMailer.password_reset(self, password_reset_token).deliver_now
7572
end
7673

7774
def reconfirming?

app/views/passwords/edit.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<%= form_with url: password_path(@user.password_reset_token), scope: :user, method: :put do |form| %>
1+
<%= form_with url: password_path(params[:password_reset_token]), scope: :user, method: :put do |form| %>
22
<div>
33
<%= form.label :password %>
44
<%= form.password_field :password, required: true %>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<h1>Password Reset Instructions</h1>
22

3-
<%= link_to "Click here to reset your password.", edit_password_url(@user.password_reset_token) %>
3+
<%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
Password Reset Instructions
22

3-
<%= edit_password_url(@user.password_reset_token) %>
3+
<%= edit_password_url(@password_reset_token) %>

db/migrate/20211123101413_add_password_reset_token_to_users.rb

Lines changed: 0 additions & 7 deletions
This file was deleted.

db/schema.rb

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)