diff --git a/README.md b/README.md
index 7cb4d31..5d161fb 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,10 @@ If you're like me then you probably take Devise for granted because you're too i
Fortunately, Rails gives you all the tools you need to roll your own authentication system from scratch without needing to depend on a gem. The challenge is just knowing how to account for edge cases while being cognizant of security and best practices.
+## Previous Versions
+
+This guide is continuously updated to account for best practices. You can [view previous releases here](https://github.com/stevepolitodesign/rails-authentication-from-scratch/releases).
+
## Local Development
Simply run the setup script and follow the prompts to see the final application.
@@ -64,14 +68,14 @@ end
> - We enforce unique email addresses at the database level through `add_index :users, :email, unique: true` in addition to a [uniqueness](https://guides.rubyonrails.org/active_record_validations.html#uniqueness) validation.
> - We ensure all emails are valid through a [format](https://guides.rubyonrails.org/active_record_validations.html#format) validation.
> - We save all emails to the database in a downcase format via a [before_save](https://api.rubyonrails.org/v6.1.4/classes/ActiveRecord/Callbacks/ClassMethods.html#method-i-before_save) callback such that the values are saved in a consistent format.
-> - We use [URI::MailTo::EMAIL_REGEXP](https://ruby-doc.org/stdlib-3.0.0/libdoc/uri/rdoc/URI/MailTo.html) that comes with Ruby to valid that the email address is properly formatted.
+> - We use [URI::MailTo::EMAIL_REGEXP](https://ruby-doc.org/stdlib-3.0.0/libdoc/uri/rdoc/URI/MailTo.html) that comes with Ruby to validate that the email address is properly formatted.
## Step 2: Add Confirmation and Password Columns to Users Table
1. Create migration.
```bash
-rails g migration add_confirmation_and_password_columns_to_users confirmation_token:string confirmation_sent_at:datetime confirmed_at:datetime password_digest:string
+rails g migration add_confirmation_and_password_columns_to_users confirmed_at:datetime password_digest:string
```
2. Update the migration.
@@ -80,20 +84,14 @@ rails g migration add_confirmation_and_password_columns_to_users confirmation_to
# db/migrate/[timestamp]_add_confirmation_and_password_columns_to_users.rb
class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
def change
- add_column :users, :confirmation_token, :string, null: false
- add_column :users, :confirmation_sent_at, :datetime
add_column :users, :confirmed_at, :datetime
add_column :users, :password_digest, :string, null: false
-
- add_index :users, :confirmation_token, unique: true
end
end
```
> **What's Going On Here?**
>
-> - 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.
-> - 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.
> - 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.
> - 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.
@@ -121,17 +119,15 @@ bundle install
```ruby
# app/models/user.rb
class User < ApplicationRecord
- CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i
+ CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
has_secure_password
- has_secure_token :confirmation_token
before_save :downcase_email
validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
def confirm!
- regenerate_confirmation_token
update_columns(confirmed_at: Time.current)
end
@@ -139,9 +135,8 @@ class User < ApplicationRecord
confirmed_at.present?
end
- def confirmation_token_is_valid?
- return false if confirmation_sent_at.nil?
- (Time.current - confirmation_sent_at) <= User::CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS
+ def generate_confirmation_token
+ signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
end
def unconfirmed?
@@ -159,11 +154,9 @@ end
> **What's Going On Here?**
>
> - 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.
-> - 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.
> - The `confirm!` method will be called when a user confirms their email address. We still need to build this feature.
-> - Note that we call `regenerate_confirmation_token` to ensure their `confirmation_token` is reset so that it cannot be used again.
> - The `confirmed?` and `unconfirmed?` methods allow us to tell whether a user has confirmed their email address or not.
-> - 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.
+> - 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.
## Step 3: Create Sign Up Pages
@@ -173,7 +166,7 @@ end
rails g controller StaticPages home
```
-2. Create UsersController.
+2. Create Users Controller.
```
rails g controller Users
@@ -251,7 +244,7 @@ end
Users now have a way to sign up, but we need to verify their email address to prevent SPAM.
-1. Create ConfirmationsController
+1. Create Confirmations Controller.
```
rails g controller Confirmations
@@ -272,9 +265,9 @@ class ConfirmationsController < ApplicationController
end
def edit
- @user = User.find_by(confirmation_token: params[:confirmation_token])
+ @user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
- if @user.present? && @user.confirmation_token_is_valid?
+ if @user.present?
@user.confirm!
redirect_to root_path, notice: "Your account has been confirmed."
else
@@ -314,7 +307,8 @@ end
> **What's Going On Here?**
>
> - 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.
-> - 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.
+> - 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).
+> - 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.
## Step 5: Create Confirmation Mailer
@@ -331,8 +325,9 @@ rails g mailer User confirmation
class UserMailer < ApplicationMailer
default from: User::MAILER_FROM_EMAIL
- def confirmation(user)
+ def confirmation(user, confirmation_token)
@user = user
+ @confirmation_token = confirmation_token
mail to: @user.email, subject: "Confirmation Instructions"
end
@@ -343,14 +338,14 @@ end
<%= form.label :password %>
<%= form.password_field :password, required: true %>
@@ -826,7 +783,7 @@ end
## Step 11: Add Unconfirmed Email Column To Users Table
-1. Create migration and run migration
+1. Create and run migration.
```bash
rails g migration add_unconfirmed_email_to_users unconfirmed_email:string
@@ -850,7 +807,6 @@ class User < ApplicationRecord
if unconfirmed_email.present?
return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
end
- regenerate_confirmation_token
update_columns(confirmed_at: Time.current)
else
false
@@ -885,7 +841,7 @@ end
> **What's Going On Here?**
>
-> - We add a `unconfirmed_email` column to the `users_table` so that we have a place to store the email a user is trying to use after their account has been confirmed with their original email.
+> - We add a `unconfirmed_email` column to the `users` table so that we have a place to store the email a user is trying to use after their account has been confirmed with their original email.
> - We add `attr_accessor :current_password` so that we'll be able to use `f.password_field :current_password` in the user form (which doesn't exist yet). This will allow us to require the user to submit their current password before they can update their account.
> - We ensure to format the `unconfirmed_email` before saving it to the database. This ensures all data is saved consistently.
> - We add validations to the `unconfirmed_email` column ensuring it's a valid email address.
@@ -914,7 +870,7 @@ class ConfirmationsController < ApplicationController
...
def edit
...
- if @user.present? && @user.confirmation_token_is_valid?
+ if @user.present?
if @user.confirm!
login @user
redirect_to root_path, notice: "Your account has been confirmed."
@@ -935,7 +891,7 @@ end
## Step 12: Update Users Controller
-1. Update Authentication Concern
+1. Update Authentication Concern.
```ruby
# app/controllers/concerns/authentication.rb
@@ -1007,7 +963,7 @@ end
> **What's Going On Here?**
>
-> - We call `redirect_if_authenticated` before editing, destroying, or updating a user since only an authenticated use should be able to do this.
+> - We call `redirect_if_authenticated` before editing, destroying, or updating a user since only an authenticated user should be able to do this.
> - We update the `create` method to accept `create_user_params` (formerly `user_params`). This is because we're going to require different parameters for creating an account vs. editing an account.
> - The `destroy` action simply deletes the user and logs them out. Note that we're calling `current_user`, so this action can only be scoped to the user who is logged in.
> - The `edit` action simply assigns `@user` to the `current_user` so that we have access to the user in the edit form.
@@ -1075,7 +1031,7 @@ class ConfirmationsController < ApplicationController
...
def edit
...
- if @user.present? && @user.unconfirmed_or_reconfirming? && @user.confirmation_token_is_valid?
+ if @user.present? && @user.unconfirmed_or_reconfirming?
...
end
end
@@ -1131,7 +1087,7 @@ end
> **What's Going On Here?**
>
-> - Just like the `confirmation_token` and `password_reset_token` columns, we call [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) on the `remember_token`. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user.
+> - We call [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) on the `remember_token`. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user.
## Step 15: Update Authentication Concern
@@ -1167,7 +1123,7 @@ end
> **What's Going On Here?**
>
-> - The `remember` method first regenerates a new `remember_token` to ensure these values are being rotated and can't be used more than once. We get the `regenerate_remember_token` method from [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token). Next, we assigned this value to a [cookie](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html). The call to [permanent](https://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-permanent) ensures the cookie won't expire until 20 years from now. The call to [encrypted](https://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-encrypted) ensures the value will be encrypted. This is vital since this value is used to identify the user and is being set in the browser.
+> - The `remember` method first regenerates a new `remember_token` to ensure these values are being rotated and can't be used more than once. We get the `regenerate_remember_token` method from [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token). Next, we assign this value to a [cookie](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html). The call to [permanent](https://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-permanent) ensures the cookie won't expire until 20 years from now. The call to [encrypted](https://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-encrypted) ensures the value will be encrypted. This is vital since this value is used to identify the user and is being set in the browser.
> - The `forget` method deletes the cookie and regenerates a new `remember_token` to ensure these values are being rotated and can't be used more than once.
> - We update the `current_user` method by adding a conditional to first try and find the user by the session, and then fallback to finding the user by the cookie. This is the logic that allows a user to completely exit their browser and remain logged in when they return to the website since the cookie will still be set.
@@ -1248,7 +1204,7 @@ end
> **What's Going On Here?**
>
-> - The `store_location` method stores the [request.original_url](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-original_url) in the [session](https://guides.rubyonrails.org/action_controller_overview.html#session) so it can be retrieved later. We only do this if the request made was a get request. We also call `request.local?` to ensure it was a local request. This prevents redirecting to an external application.
+> - The `store_location` method stores the [request.original_url](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-original_url) in the [session](https://guides.rubyonrails.org/action_controller_overview.html#session) so it can be retrieved later. We only do this if the request made was a `get` request. We also call `request.local?` to ensure it was a local request. This prevents redirecting to an external application.
> - We call `store_location` in the `authenticate_user!` method so that we can save the path to the page the user was trying to visit before they were redirected to the login page. We need to do this before visiting the login page otherwise the call to `request.original_url` will always return the url to the login page.
2. Update Sessions Controller.
@@ -1372,7 +1328,7 @@ end
> **What's Going On Here?**
>
-> - Similar to the `confirmation_token`, `password_reset_token` and `remember_token`, prevent the `session_token` from being null and enforce that it has a unique value.
+> - Similar to the `remember_token` column, we prevent the `session_token` from being null and enforce that it has a unique value.
3. Update User Model.
@@ -1418,7 +1374,7 @@ end
> **What's Going On Here?**
>
-> - We update the `login` method by adding a call to `user.regenerate_session_token`. This will reset the valid of the `session_token` through the [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) API. We then store that value in the session.
+> - We update the `login` method by adding a call to `user.regenerate_session_token`. This will reset the value of the `session_token` through the [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) API. We then store that value in the session.
> - We updated the `logout` method by first setting the `current_user` as a variable. This is because once we call `reset_session`, we lose access to the `current_user`. We then call `user.regenerate_session_token` which will update the value of the `session_token` on the user that just signed out.
> - Finally we update the `current_user` method to look for the `session[:current_user_session_token]` instead of the `session[:current_user_id]` and to query for the User by the `session_token` value.
@@ -1427,7 +1383,7 @@ end
```ruby
# config/environments/production.rb
Rails.application.configure do
- ...
+ ...
config.force_ssl = true
end
```
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 44c656e..a65fc68 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -13,8 +13,8 @@ def create
end
def edit
- @user = User.find_by(confirmation_token: params[:confirmation_token])
- if @user.present? && @user.unconfirmed_or_reconfirming? && @user.confirmation_token_is_valid?
+ @user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
+ if @user.present? && @user.unconfirmed_or_reconfirming?
if @user.confirm!
login @user
redirect_to root_path, notice: "Your account has been confirmed."
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 814bdce..abb6576 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -16,10 +16,10 @@ def create
end
def edit
- @user = User.find_by(password_reset_token: params[:password_reset_token])
+ @user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
if @user.present? && @user.unconfirmed?
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
- elsif @user.nil? || @user.password_reset_token_has_expired?
+ elsif @user.nil?
redirect_to new_password_path, alert: "Invalid or expired token."
end
end
@@ -28,21 +28,18 @@ def new
end
def update
- @user = User.find_by(password_reset_token: params[:password_reset_token])
+ @user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
if @user
if @user.unconfirmed?
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
- elsif @user.password_reset_token_has_expired?
- redirect_to new_password_path, alert: "Incorrect email or password."
elsif @user.update(password_params)
- @user.regenerate_password_reset_token
- redirect_to login_path, notice: "Password updated."
+ redirect_to login_path, notice: "Sign in."
else
flash.now[:alert] = @user.errors.full_messages.to_sentence
render :edit, status: :unprocessable_entity
end
else
- flash.now[:alert] = "Incorrect email or password."
+ flash.now[:alert] = "Invalid or expired token."
render :new, status: :unprocessable_entity
end
end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 6df867a..ae2c631 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -6,14 +6,16 @@ class UserMailer < ApplicationMailer
#
# en.user_mailer.confirmation.subject
#
- def confirmation(user)
+ def confirmation(user, confirmation_token)
@user = user
+ @confirmation_token = confirmation_token
mail to: @user.confirmable_email, subject: "Confirmation Instructions"
end
- def password_reset(user)
+ def password_reset(user, password_reset_token)
@user = user
+ @password_reset_token = password_reset_token
mail to: @user.email, subject: "Password Reset Instructions"
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 1610741..3e42202 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,13 +1,11 @@
class User < ApplicationRecord
- CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i
+ CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
MAILER_FROM_EMAIL = "no-reply@example.com"
- PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i
+ PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes
attr_accessor :current_password
has_secure_password
- has_secure_token :confirmation_token
- has_secure_token :password_reset_token
has_secure_token :remember_token
has_secure_token :session_token
@@ -37,7 +35,6 @@ def confirm!
if unconfirmed_email.present?
return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
end
- regenerate_confirmation_token
update_columns(confirmed_at: Time.current)
else
false
@@ -56,26 +53,22 @@ def confirmable_email
end
end
- def confirmation_token_is_valid?
- return false if confirmation_sent_at.nil?
- (Time.current - confirmation_sent_at) <= User::CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS
+ def generate_confirmation_token
+ signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
end
- def password_reset_token_has_expired?
- return true if password_reset_sent_at.nil?
- (Time.current - password_reset_sent_at) >= User::PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS
+ def generate_password_reset_token
+ signed_id expires_in: PASSWORD_RESET_TOKEN_EXPIRATION, purpose: :reset_password
end
def send_confirmation_email!
- regenerate_confirmation_token
- update_columns(confirmation_sent_at: Time.current)
- UserMailer.confirmation(self).deliver_now
+ confirmation_token = generate_confirmation_token
+ UserMailer.confirmation(self, confirmation_token).deliver_now
end
def send_password_reset_email!
- regenerate_password_reset_token
- update_columns(password_reset_sent_at: Time.current)
- UserMailer.password_reset(self).deliver_now
+ password_reset_token = generate_password_reset_token
+ UserMailer.password_reset(self, password_reset_token).deliver_now
end
def reconfirming?
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb
index b9801b3..899edd5 100644
--- a/app/views/passwords/edit.html.erb
+++ b/app/views/passwords/edit.html.erb
@@ -1,4 +1,4 @@
-<%= form_with url: password_path(@user.password_reset_token), scope: :user, method: :put do |form| %>
+<%= form_with url: password_path(params[:password_reset_token]), scope: :user, method: :put do |form| %>
<%= form.label :password %>
<%= form.password_field :password, required: true %>
diff --git a/app/views/user_mailer/confirmation.html.erb b/app/views/user_mailer/confirmation.html.erb
index 0ca67c7..d064deb 100644
--- a/app/views/user_mailer/confirmation.html.erb
+++ b/app/views/user_mailer/confirmation.html.erb
@@ -1,3 +1,3 @@
Confirmation Instructions
-<%= link_to "Click here to confirm your email.", edit_confirmation_url(@user.confirmation_token) %>
\ No newline at end of file
+<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation.text.erb b/app/views/user_mailer/confirmation.text.erb
index c6a8e8c..a34b236 100644
--- a/app/views/user_mailer/confirmation.text.erb
+++ b/app/views/user_mailer/confirmation.text.erb
@@ -1,3 +1,3 @@
Confirmation Instructions
-<%= edit_confirmation_url(@user.confirmation_token) %>
\ No newline at end of file
+<%= edit_confirmation_url(@confirmation_token) %>
\ No newline at end of file
diff --git a/app/views/user_mailer/password_reset.html.erb b/app/views/user_mailer/password_reset.html.erb
index ad2c8e8..944c522 100644
--- a/app/views/user_mailer/password_reset.html.erb
+++ b/app/views/user_mailer/password_reset.html.erb
@@ -1,3 +1,3 @@
Password Reset Instructions
-<%= link_to "Click here to reset your password.", edit_password_url(@user.password_reset_token) %>
\ No newline at end of file
+<%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %>
\ No newline at end of file
diff --git a/app/views/user_mailer/password_reset.text.erb b/app/views/user_mailer/password_reset.text.erb
index 9e45a26..57bd0ad 100644
--- a/app/views/user_mailer/password_reset.text.erb
+++ b/app/views/user_mailer/password_reset.text.erb
@@ -1,3 +1,3 @@
Password Reset Instructions
-<%= edit_password_url(@user.password_reset_token) %>
\ No newline at end of file
+<%= edit_password_url(@password_reset_token) %>
\ No newline at end of file
diff --git a/db/migrate/20211112152821_add_confirmation_and_password_columns_to_users.rb b/db/migrate/20211112152821_add_confirmation_and_password_columns_to_users.rb
index 3fdeaba..2cb7d15 100644
--- a/db/migrate/20211112152821_add_confirmation_and_password_columns_to_users.rb
+++ b/db/migrate/20211112152821_add_confirmation_and_password_columns_to_users.rb
@@ -1,17 +1,6 @@
class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
def change
- # This will be used to identify a user in a secure way. We don't ever want it to be empty.
- # This value will automatically be set on save.
- # https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token
- add_column :users, :confirmation_token, :string, null: false
- add_column :users, :confirmation_sent_at, :datetime
add_column :users, :confirmed_at, :datetime
-
- # This will store the hashed value of the password. We don't ever want it to be empty.
- # https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password
add_column :users, :password_digest, :string, null: false
-
- # This will ensure the confirmation_token is unique.
- add_index :users, :confirmation_token, unique: true
end
end
diff --git a/db/migrate/20211123101413_add_password_reset_token_to_users.rb b/db/migrate/20211123101413_add_password_reset_token_to_users.rb
deleted file mode 100644
index f3f6890..0000000
--- a/db/migrate/20211123101413_add_password_reset_token_to_users.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class AddPasswordResetTokenToUsers < ActiveRecord::Migration[6.1]
- def change
- add_column :users, :password_reset_token, :string, null: false
- add_column :users, :password_reset_sent_at, :datetime
- add_index :users, :password_reset_token, unique: true
- end
-end
diff --git a/db/schema.rb b/db/schema.rb
index cb659bd..c1b52bc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -16,18 +16,12 @@
t.string "email", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
- t.string "confirmation_token", null: false
- t.datetime "confirmation_sent_at"
t.datetime "confirmed_at"
t.string "password_digest", null: false
- t.string "password_reset_token", null: false
- t.datetime "password_reset_sent_at"
t.string "unconfirmed_email"
t.string "remember_token", null: false
t.string "session_token", null: false
- t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
- t.index ["password_reset_token"], name: "index_users_on_password_reset_token", unique: true
t.index ["remember_token"], name: "index_users_on_remember_token", unique: true
t.index ["session_token"], name: "index_users_on_session_token", unique: true
end
diff --git a/test/controllers/confirmations_controller_test.rb b/test/controllers/confirmations_controller_test.rb
index 8449312..d196308 100644
--- a/test/controllers/confirmations_controller_test.rb
+++ b/test/controllers/confirmations_controller_test.rb
@@ -2,38 +2,39 @@
class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
setup do
- @reconfirmed_user = User.create!(email: "reconfirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago, confirmation_sent_at: 1.week.ago, unconfirmed_email: "unconfirmed_email@example.com")
- @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago, confirmation_sent_at: 1.week.ago)
- @unconfirmed_user = User.create!(email: "unconfirmed_user@example.com", confirmation_sent_at: Time.current, password: "password", password_confirmation: "password")
+ @reconfirmed_user = User.create!(email: "reconfirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago, unconfirmed_email: "unconfirmed_email@example.com")
+ @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago)
+ @unconfirmed_user = User.create!(email: "unconfirmed_user@example.com", password: "password", password_confirmation: "password")
end
test "should confirm unconfirmed user" do
- freeze_time
+ freeze_time do
+ confirmation_token = @unconfirmed_user.generate_confirmation_token
- assert_changes "@unconfirmed_user.reload.confirmation_token" do
- get edit_confirmation_path(@unconfirmed_user.confirmation_token)
- end
+ get edit_confirmation_path(confirmation_token)
- assert_equal Time.current, @unconfirmed_user.reload.confirmed_at
- assert @unconfirmed_user.reload.confirmed?
- assert_redirected_to root_path
- assert_not_nil flash[:notice]
+ assert @unconfirmed_user.reload.confirmed?
+ assert_equal Time.now, @unconfirmed_user.confirmed_at
+ assert_redirected_to root_path
+ assert_not_nil flash[:notice]
+ end
end
test "should reconfirm confirmed user" do
unconfirmed_email = @reconfirmed_user.unconfirmed_email
- freeze_time
- @reconfirmed_user.send_confirmation_email!
+ freeze_time do
+ confirmation_token = @reconfirmed_user.generate_confirmation_token
- get edit_confirmation_path(@reconfirmed_user.confirmation_token)
+ get edit_confirmation_path(confirmation_token)
- assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
- assert @reconfirmed_user.reload.confirmed?
- assert_equal unconfirmed_email, @reconfirmed_user.reload.email
- assert_nil @reconfirmed_user.reload.unconfirmed_email
- assert_redirected_to root_path
- assert_not_nil flash[:notice]
+ assert @reconfirmed_user.reload.confirmed?
+ assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
+ assert_equal unconfirmed_email, @reconfirmed_user.reload.email
+ assert_nil @reconfirmed_user.reload.unconfirmed_email
+ assert_redirected_to root_path
+ assert_not_nil flash[:notice]
+ end
end
test "should not update email address if already taken" do
@@ -41,9 +42,9 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
@reconfirmed_user.update(unconfirmed_email: @confirmed_user.email)
freeze_time do
- @reconfirmed_user.send_confirmation_email!
+ confirmation_token = @reconfirmed_user.generate_confirmation_token
- get edit_confirmation_path(@reconfirmed_user.confirmation_token)
+ get edit_confirmation_path(confirmation_token)
assert_equal original_email, @reconfirmed_user.reload.email
assert_redirected_to new_confirmation_path
@@ -52,14 +53,16 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
end
test "should redirect if confirmation link expired" do
- travel_to 601.seconds.from_now
+ confirmation_token = @unconfirmed_user.generate_confirmation_token
- get edit_confirmation_path(@unconfirmed_user.confirmation_token)
+ travel_to 601.seconds.from_now do
+ get edit_confirmation_path(confirmation_token)
- assert_nil @unconfirmed_user.reload.confirmed_at
- assert_not @unconfirmed_user.reload.confirmed?
- assert_redirected_to new_confirmation_path
- assert_not_nil flash[:alert]
+ assert_nil @unconfirmed_user.reload.confirmed_at
+ assert_not @unconfirmed_user.reload.confirmed?
+ assert_redirected_to new_confirmation_path
+ assert_not_nil flash[:alert]
+ end
end
test "should redirect if confirmation link is incorrect" do
@@ -91,39 +94,39 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
end
test "should prevent authenticated user from confirming" do
- freeze_time
+ freeze_time do
+ confirmation_token = @confirmed_user.generate_confirmation_token
- @reconfirmed_user.send_confirmation_email!
- login @confirmed_user
+ login @confirmed_user
- get edit_confirmation_path(@confirmed_user.confirmation_token)
+ get edit_confirmation_path(confirmation_token)
- assert_not_equal Time.current, @confirmed_user.reload.confirmed_at
- assert_redirected_to new_confirmation_path
- assert_not_nil flash[:alert]
+ assert_not_equal Time.current, @confirmed_user.reload.confirmed_at
+ assert_redirected_to new_confirmation_path
+ assert_not_nil flash[:alert]
+ end
end
test "should not prevent authenticated user confirming their unconfirmed_email" do
unconfirmed_email = @reconfirmed_user.unconfirmed_email
- freeze_time
- login(@reconfirmed_user)
+ freeze_time do
+ login @reconfirmed_user
- @reconfirmed_user.send_confirmation_email!
+ confirmation_token = @reconfirmed_user.generate_confirmation_token
- get edit_confirmation_path(@reconfirmed_user.confirmation_token)
+ get edit_confirmation_path(confirmation_token)
- assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
- assert @reconfirmed_user.reload.confirmed?
- assert_equal unconfirmed_email, @reconfirmed_user.reload.email
- assert_nil @reconfirmed_user.reload.unconfirmed_email
- assert_redirected_to root_path
- assert_not_nil flash[:notice]
+ assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
+ assert @reconfirmed_user.reload.confirmed?
+ assert_equal unconfirmed_email, @reconfirmed_user.reload.email
+ assert_nil @reconfirmed_user.reload.unconfirmed_email
+ assert_redirected_to root_path
+ assert_not_nil flash[:notice]
+ end
end
test "should prevent authenticated user from submitting the confirmation form" do
- freeze_time
-
login @confirmed_user
get new_confirmation_path
diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb
index f2439cf..b9485c4 100644
--- a/test/controllers/passwords_controller_test.rb
+++ b/test/controllers/passwords_controller_test.rb
@@ -6,24 +6,23 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
test "should get edit" do
- @confirmed_user.send_password_reset_email!
+ password_reset_token = @confirmed_user.generate_password_reset_token
- get edit_password_path(@confirmed_user.password_reset_token)
+ get edit_password_path(password_reset_token)
assert_response :ok
end
test "should redirect from edit if password link expired" do
- @confirmed_user.send_password_reset_email!
+ password_reset_token = @confirmed_user.generate_password_reset_token
travel_to 601.seconds.from_now
- get edit_password_path(@confirmed_user.password_reset_token)
+ get edit_password_path(password_reset_token)
assert_redirected_to new_password_path
assert_not_nil flash[:alert]
end
test "should redirect from edit if password link is incorrect" do
- travel_to 601.seconds.from_now
get edit_password_path("not_a_real_token")
assert_redirected_to new_password_path
@@ -32,16 +31,20 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "should redirect from edit if user is not confirmed" do
@confirmed_user.update!(confirmed_at: nil)
- get edit_password_path(@confirmed_user.password_reset_token)
+ password_reset_token = @confirmed_user.generate_password_reset_token
+
+ get edit_password_path(password_reset_token)
assert_redirected_to new_confirmation_path
assert_not_nil flash[:alert]
end
test "should redirect from edit if user is authenticated" do
+ password_reset_token = @confirmed_user.generate_password_reset_token
+
login @confirmed_user
- get edit_password_path(@confirmed_user.password_reset_token)
+ get edit_password_path(password_reset_token)
assert_redirected_to root_path
end
@@ -71,25 +74,23 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
test "should update password" do
- @confirmed_user.send_password_reset_email!
+ password_reset_token = @confirmed_user.generate_password_reset_token
- assert_changes "@confirmed_user.reload.password_reset_token" do
- put password_path(@confirmed_user.password_reset_token), params: {
- user: {
- password: "password",
- password_confirmation: "password"
- }
+ put password_path(password_reset_token), params: {
+ user: {
+ password: "password",
+ password_confirmation: "password"
}
- end
+ }
assert_redirected_to login_path
assert_not_nil flash[:notice]
end
test "should handle errors" do
- @confirmed_user.send_password_reset_email!
+ password_reset_token = @confirmed_user.generate_password_reset_token
- put password_path(@confirmed_user.password_reset_token), params: {
+ put password_path(password_reset_token), params: {
user: {
password: "password",
password_confirmation: "password_that_does_not_match"
@@ -100,9 +101,11 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
test "should not update password if authenticated" do
+ password_reset_token = @confirmed_user.generate_password_reset_token
+
login @confirmed_user
- put password_path(@confirmed_user.password_reset_token), params: {
+ put password_path(password_reset_token), params: {
user: {
password: "password",
password_confirmation: "password"
diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb
index e0cdfb4..9f9c9e0 100644
--- a/test/mailers/user_mailer_test.rb
+++ b/test/mailers/user_mailer_test.rb
@@ -6,18 +6,20 @@ class UserMailerTest < ActionMailer::TestCase
end
test "confirmation" do
- mail = UserMailer.confirmation(@user)
+ confirmation_token = @user.generate_confirmation_token
+ mail = UserMailer.confirmation(@user, confirmation_token)
assert_equal "Confirmation Instructions", mail.subject
assert_equal [@user.email], mail.to
assert_equal [User::MAILER_FROM_EMAIL], mail.from
- assert_match @user.confirmation_token, mail.body.encoded
+ assert_match confirmation_token, mail.body.encoded
end
test "password_reset" do
- mail = UserMailer.password_reset(@user)
+ password_reset_token = @user.generate_password_reset_token
+ mail = UserMailer.password_reset(@user, password_reset_token)
assert_equal "Password Reset Instructions", mail.subject
assert_equal [@user.email], mail.to
assert_equal [User::MAILER_FROM_EMAIL], mail.from
- assert_match @user.password_reset_token, mail.body.encoded
+ assert_match password_reset_token, mail.body.encoded
end
end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 5345923..616e6c3 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -74,73 +74,33 @@ class UserTest < ActiveSupport::TestCase
assert @user.unconfirmed_or_reconfirming?
end
- test "should confirm email" do
+ test "should send confirmation email" do
@user.save!
- original_confirmation_token = @user.confirmation_token
-
- freeze_time
-
- assert_nil @user.confirmation_sent_at
assert_emails 1 do
@user.send_confirmation_email!
end
- assert_not_equal original_confirmation_token, @user.reload.confirmation_token
- assert_equal Time.now, @user.confirmation_sent_at
assert_equal @user.email, ActionMailer::Base.deliveries.last.to[0]
end
- test "should confirm unconfirmed_email" do
+ test "should send confirmation email to unconfirmed_email" do
@user.save!
@user.update!(unconfirmed_email: "unconfirmed_email@example.com")
- previous_confirmation_token = @user.reload.confirmation_token
-
- freeze_time
assert_emails 1 do
@user.send_confirmation_email!
end
- assert_not_equal previous_confirmation_token, @user.reload.confirmation_token
- assert_equal Time.now, @user.confirmation_sent_at
assert_equal @user.unconfirmed_email, ActionMailer::Base.deliveries.last.to[0]
end
- test "should respond to confirmation_token_is_valid?" do
- assert_not @user.confirmation_token_is_valid?
-
- @user.confirmation_sent_at = 1.minute.ago
- assert @user.confirmation_token_is_valid?
-
- @user.confirmation_sent_at = 601.seconds.ago
- assert_not @user.confirmation_token_is_valid?
- end
-
test "should respond to send_password_reset_email!" do
@user.save!
- original_password_reset_token = @user.password_reset_token
-
- freeze_time
-
- assert_nil @user.password_reset_sent_at
assert_emails 1 do
@user.send_password_reset_email!
end
-
- assert_not_equal original_password_reset_token, @user.reload.password_reset_token
- assert_equal Time.now, @user.password_reset_sent_at
- end
-
- test "should respond to password_reset_token_has_expired?" do
- assert @user.password_reset_token_has_expired?
-
- @user.password_reset_sent_at = 1.minute.ago
- assert_not @user.password_reset_token_has_expired?
-
- @user.password_reset_sent_at = 601.seconds.ago
- assert @user.password_reset_token_has_expired?
end
test "should downcase unconfirmed_email" do
@@ -209,4 +169,18 @@ class UserTest < ActiveSupport::TestCase
assert_not_nil @user.reload.session_token
end
+
+ test "should generate confirmation token" do
+ @user.save!
+ confirmation_token = @user.generate_confirmation_token
+
+ assert_equal @user, User.find_signed(confirmation_token, purpose: :confirm_email)
+ end
+
+ test "should generate password reset token" do
+ @user.save!
+ password_reset_token = @user.generate_password_reset_token
+
+ assert_equal @user, User.find_signed(password_reset_token, purpose: :reset_password)
+ end
end