Skip to content

Commit 37f16b5

Browse files
Use signed_id to confirm user.
This is more secure, and also requires less columns.
1 parent c85e3ad commit 37f16b5

File tree

11 files changed

+85
-129
lines changed

11 files changed

+85
-129
lines changed

README.md

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ end
7171
1. Create migration.
7272

7373
```bash
74-
rails g migration add_confirmation_and_password_columns_to_users confirmation_token:string confirmation_sent_at:datetime confirmed_at:datetime password_digest:string
74+
rails g migration add_confirmation_and_password_columns_to_users confirmed_at:datetime password_digest:string
7575
```
7676

7777
2. Update the migration.
@@ -80,20 +80,14 @@ rails g migration add_confirmation_and_password_columns_to_users confirmation_to
8080
# db/migrate/[timestamp]_add_confirmation_and_password_columns_to_users.rb
8181
class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
8282
def change
83-
add_column :users, :confirmation_token, :string, null: false
84-
add_column :users, :confirmation_sent_at, :datetime
8583
add_column :users, :confirmed_at, :datetime
8684
add_column :users, :password_digest, :string, null: false
87-
88-
add_index :users, :confirmation_token, unique: true
8985
end
9086
end
9187
```
9288

9389
> **What's Going On Here?**
9490
>
95-
> - The `confirmation_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 we need to confirm their email address. We add `null: false` to prevent empty values and also add a unique index to ensure that no two users will have the same `confirmation_token`. You can think of this as a secure alternative to the `id` column.
96-
> - The `confirmation_sent_at` column will be used to ensure a confirmation has not expired. This is an added layer of security to prevent a `confirmation_token` from being used multiple times.
9791
> - The `confirmed_at` column will be set when a user confirms their account. This will help us determine who has confirmed their account and who has not.
9892
> - The `password_digest` column will store a hashed version of the user's password. This is provided by the [has_secure_password](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password) method.
9993
@@ -121,27 +115,24 @@ bundle install
121115
```ruby
122116
# app/models/user.rb
123117
class User < ApplicationRecord
124-
CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i
118+
CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
125119

126120
has_secure_password
127-
has_secure_token :confirmation_token
128121

129122
before_save :downcase_email
130123

131124
validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
132125

133126
def confirm!
134-
regenerate_confirmation_token
135127
update_columns(confirmed_at: Time.current)
136128
end
137129

138130
def confirmed?
139131
confirmed_at.present?
140132
end
141133

142-
def confirmation_token_is_valid?
143-
return false if confirmation_sent_at.nil?
144-
(Time.current - confirmation_sent_at) <= User::CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS
134+
def generate_confirmation_token
135+
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
145136
end
146137

147138
def unconfirmed?
@@ -159,11 +150,9 @@ end
159150
> **What's Going On Here?**
160151
>
161152
> - The `has_secure_password` method is added to give us an [API](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password) to work with the `password_digest` column.
162-
> - The `has_secure_token :confirmation_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 `confirmation_token` column.
163153
> - The `confirm!` method will be called when a user confirms their email address. We still need to build this feature.
164-
> - Note that we call `regenerate_confirmation_token` to ensure their `confirmation_token` is reset so that it cannot be used again.
165154
> - The `confirmed?` and `unconfirmed?` methods allow us to tell whether a user has confirmed their email address or not.
166-
> - The `confirmation_token_is_valid?` method tells us if the confirmation token is expired or not. This can be controlled by changing the value of the `CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS` constant. This will be useful when we build the confirmation mailer.
155+
> - The `generate_confirmation_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 `CONFIRMATION_TOKEN_EXPIRATION` constant) and give it an explicit purpose of `:confirm_email`. This will be useful when we build the confirmation mailer.
167156
168157
## Step 3: Create Sign Up Pages
169158

@@ -272,9 +261,9 @@ class ConfirmationsController < ApplicationController
272261
end
273262

274263
def edit
275-
@user = User.find_by(confirmation_token: params[:confirmation_token])
264+
@user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
276265

277-
if @user.present? && @user.confirmation_token_is_valid?
266+
if @user.present?
278267
@user.confirm!
279268
redirect_to root_path, notice: "Your account has been confirmed."
280269
else
@@ -314,7 +303,8 @@ end
314303
> **What's Going On Here?**
315304
>
316305
> - The `create` action will be used to resend confirmation instructions to an unconfirmed user. We still need to build this mailer, and we still need to send this mailer when a user initially signs up. This action will be requested via the form on `app/views/confirmations/new.html.erb`. Note that we call `downcase` on the email to account for case sensitivity when searching.
317-
> - 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 their `confirmation_token` and not their email or ID. This is because The `confirmation_token` is randomly generated and can't be easily guessed 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). Note that we check if their confirmation token has expired before confirming their account.
306+
> - 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).
307+
> - You'll remember that the `confirmation_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: :confirm_email` to be consistent with the purpose that was set in the `generate_confirmation_token` method.
318308
319309
## Step 5: Create Confirmation Mailer
320310

@@ -331,8 +321,9 @@ rails g mailer User confirmation
331321
class UserMailer < ApplicationMailer
332322
default from: User::MAILER_FROM_EMAIL
333323

334-
def confirmation(user)
324+
def confirmation(user, confirmation_token)
335325
@user = user
326+
@confirmation_token = confirmation_token
336327

337328
mail to: @user.email, subject: "Confirmation Instructions"
338329
end
@@ -343,14 +334,14 @@ end
343334
<!-- app/views/user_mailer/confirmation.html.erb -->
344335
<h1>Confirmation Instructions</h1>
345336
346-
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@user.confirmation_token) %>
337+
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
347338
```
348339

349340
```html+erb
350341
<!-- app/views/user_mailer/confirmation.text.erb -->
351342
Confirmation Instructions
352343
353-
<%= edit_confirmation_url(@user.confirmation_token) %>
344+
<%= edit_confirmation_url(@confirmation_token) %>
354345
```
355346

356347
2. Update User Model.
@@ -362,9 +353,8 @@ class User < ApplicationRecord
362353
MAILER_FROM_EMAIL = "no-reply@example.com"
363354
...
364355
def send_confirmation_email!
365-
regenerate_confirmation_token
366-
update_columns(confirmation_sent_at: Time.current)
367-
UserMailer.confirmation(self).deliver_now
356+
confirmation_token = generate_confirmation_token
357+
UserMailer.confirmation(self, confirmation_token).deliver_now
368358
end
369359

370360
end
@@ -373,7 +363,7 @@ end
373363
> **What's Going On Here?**
374364
>
375365
> - The `MAILER_FROM_EMAIL` constant is a way for us to set the email used in the `UserMailer`. This is optional.
376-
> - The `send_confirmation_email!` method will create a new `confirmation_token` and update the value of `confirmation_sent_at`. This is to ensure confirmation links expire and cannot be reused. It will also send the confirmation email to the user.
366+
> - The `send_confirmation_email!` method will create a new `confirmation_token`. This is to ensure confirmation links expire and cannot be reused. It will also send the confirmation email to the user.
377367
> - We call [update_columns](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-update_columns) so that the `updated_at/updated_on` columns are not updated. This is personal preference, but those columns should typically only be updated when the user updates their email or password.
378368
> - The links in the mailer will take the user to `ConfirmationsController#edit` at which point they'll be confirmed.
379369
@@ -593,7 +583,7 @@ class ConfirmationsController < ApplicationController
593583

594584
def edit
595585
...
596-
if @user.present? && @user.confirmation_token_is_valid?
586+
if @user.present?
597587
@user.confirm!
598588
login @user
599589
...
@@ -850,7 +840,6 @@ class User < ApplicationRecord
850840
if unconfirmed_email.present?
851841
return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
852842
end
853-
regenerate_confirmation_token
854843
update_columns(confirmed_at: Time.current)
855844
else
856845
false
@@ -914,7 +903,7 @@ class ConfirmationsController < ApplicationController
914903
...
915904
def edit
916905
...
917-
if @user.present? && @user.confirmation_token_is_valid?
906+
if @user.present?
918907
if @user.confirm!
919908
login @user
920909
redirect_to root_path, notice: "Your account has been confirmed."
@@ -1075,7 +1064,7 @@ class ConfirmationsController < ApplicationController
10751064
...
10761065
def edit
10771066
...
1078-
if @user.present? && @user.unconfirmed_or_reconfirming? && @user.confirmation_token_is_valid?
1067+
if @user.present? && @user.unconfirmed_or_reconfirming?
10791068
...
10801069
end
10811070
end

app/controllers/confirmations_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ def create
1313
end
1414

1515
def edit
16-
@user = User.find_by(confirmation_token: params[:confirmation_token])
17-
if @user.present? && @user.unconfirmed_or_reconfirming? && @user.confirmation_token_is_valid?
16+
@user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
17+
if @user.present? && @user.unconfirmed_or_reconfirming?
1818
if @user.confirm!
1919
login @user
2020
redirect_to root_path, notice: "Your account has been confirmed."

app/mailers/user_mailer.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ class UserMailer < ApplicationMailer
66
#
77
# en.user_mailer.confirmation.subject
88
#
9-
def confirmation(user)
9+
def confirmation(user, confirmation_token)
1010
@user = user
11+
@confirmation_token = confirmation_token
1112

1213
mail to: @user.confirmable_email, subject: "Confirmation Instructions"
1314
end

app/models/user.rb

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

66
attr_accessor :current_password
77

88
has_secure_password
9-
has_secure_token :confirmation_token
109
has_secure_token :password_reset_token
1110
has_secure_token :remember_token
1211
has_secure_token :session_token
@@ -37,7 +36,6 @@ def confirm!
3736
if unconfirmed_email.present?
3837
return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
3938
end
40-
regenerate_confirmation_token
4139
update_columns(confirmed_at: Time.current)
4240
else
4341
false
@@ -56,9 +54,8 @@ def confirmable_email
5654
end
5755
end
5856

59-
def confirmation_token_is_valid?
60-
return false if confirmation_sent_at.nil?
61-
(Time.current - confirmation_sent_at) <= User::CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS
57+
def generate_confirmation_token
58+
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
6259
end
6360

6461
def password_reset_token_has_expired?
@@ -67,9 +64,8 @@ def password_reset_token_has_expired?
6764
end
6865

6966
def send_confirmation_email!
70-
regenerate_confirmation_token
71-
update_columns(confirmation_sent_at: Time.current)
72-
UserMailer.confirmation(self).deliver_now
67+
confirmation_token = generate_confirmation_token
68+
UserMailer.confirmation(self, confirmation_token).deliver_now
7369
end
7470

7571
def send_password_reset_email!
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<h1>Confirmation Instructions</h1>
22

3-
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@user.confirmation_token) %>
3+
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
Confirmation Instructions
22

3-
<%= edit_confirmation_url(@user.confirmation_token) %>
3+
<%= edit_confirmation_url(@confirmation_token) %>
Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
22
def change
3-
# This will be used to identify a user in a secure way. We don't ever want it to be empty.
4-
# This value will automatically be set on save.
5-
# https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token
6-
add_column :users, :confirmation_token, :string, null: false
7-
add_column :users, :confirmation_sent_at, :datetime
83
add_column :users, :confirmed_at, :datetime
9-
10-
# This will store the hashed value of the password. We don't ever want it to be empty.
11-
# https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password
124
add_column :users, :password_digest, :string, null: false
13-
14-
# This will ensure the confirmation_token is unique.
15-
add_index :users, :confirmation_token, unique: true
165
end
176
end

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)