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

Confirmation Instructions

-<%= link_to "Click here to confirm your email.", edit_confirmation_url(@user.confirmation_token) %> +<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %> ``` ```html+erb Confirmation Instructions -<%= edit_confirmation_url(@user.confirmation_token) %> +<%= edit_confirmation_url(@confirmation_token) %> ``` 2. Update User Model. @@ -362,9 +357,8 @@ class User < ApplicationRecord MAILER_FROM_EMAIL = "no-reply@example.com" ... 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 end @@ -373,7 +367,7 @@ end > **What's Going On Here?** > > - The `MAILER_FROM_EMAIL` constant is a way for us to set the email used in the `UserMailer`. This is optional. -> - 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. +> - 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. > - 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. > - The links in the mailer will take the user to `ConfirmationsController#edit` at which point they'll be confirmed. @@ -569,7 +563,7 @@ end <%= form.label :password %> <%= form.password_field :password, required: true %> - <%= form.submit %> + <%= form.submit "Sign In" %> <% end %> ``` @@ -593,7 +587,7 @@ class ConfirmationsController < ApplicationController def edit ... - if @user.present? && @user.confirmation_token_is_valid? + if @user.present? @user.confirm! login @user ... @@ -614,70 +608,37 @@ class UsersController < ApplicationController end ``` -## Step 9: Add Password Reset Columns to Users Table - -1. Create migration. - -```bash -rails g migration add_password_reset_token_to_users password_reset_token:string password_reset_sent_at:datetime -``` +## Step 9: Add Password Reset Functionality -2. Update the migration. - -```ruby -# db/migrate/[timestamp]_add_password_reset_token_to_users.rb -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 -``` - -> **What's Going On Here?** -> -> - 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. -> - 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. - -3. Run migration. - -```bash -rails db:migrate -``` - -4. Update User Model. +1. Update User Model. ```ruby # app/models/user.rb class User < ApplicationRecord ... - PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS = 10.minutes.to_i - ... - has_secure_token :password_reset_token + PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes ... - 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_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 ... end ``` -5. Update User Mailer. +2. Update User Mailer. ```ruby # app/mailers/user_mailer.rb class UserMailer < ApplicationMailer ... - 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 @@ -688,25 +649,24 @@ end

Password Reset Instructions

-<%= link_to "Click here to reset your password.", edit_password_url(@user.password_reset_token) %> +<%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %> ``` ```text Password Reset Instructions -<%= edit_password_url(@user.password_reset_token) %> +<%= edit_password_url(@password_reset_token) %> ``` > **What's Going On Here?** > -> - 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. -> - 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. -> - 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. +> - 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`. +> - 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. ## Step 10: Build Password Reset Forms -1. Create PasswordsController. +1. Create Passwords Controller. ```bash rails g controller Passwords @@ -732,10 +692,10 @@ class PasswordsController < ApplicationController 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 @@ -744,21 +704,18 @@ class PasswordsController < ApplicationController 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: "Signed in." + 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 @@ -774,12 +731,12 @@ end > **What's Going On Here?** > > - 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. -> - 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. -> - 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. +> - 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_password_reset_token` method. +> - 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. +> - 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. > - The `new` action simply renders a form for the user to put their email address in to receive the password reset email. > - 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. -> - 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. -> - Note that we call `@user.regenerate_password_reset_token` to ensure their `password_reset_token` is reset so that it cannot be used again. +> - 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. 2. Update Routes. @@ -793,7 +750,7 @@ end > **What's Going On Here?** > -> - We add `param: :password_reset_token` as a [named route parameter](https://guides.rubyonrails.org/routing.html#overriding-named-route-parameters) to the so that we can identify users by their `password_reset_token` and not `id`. This is similar to what we did with the confirmations routes and ensures a user cannot be identified by their ID. +> - We add `param: :password_reset_token` as a [named route parameter](https://guides.rubyonrails.org/routing.html#overriding-named-route-parameters) so that we can identify users by their `password_reset_token` and not `id`. This is similar to what we did with the confirmations routes and ensures a user cannot be identified by their ID. 3. Build forms. @@ -807,7 +764,7 @@ end ```html+ruby -<%= 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 %> @@ -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