Bcrypt gem

Authentication involves validating password provided by a user. For security reasons passwords should be stored in an encrypted form rather than in the plain text. In this way if the database is ever compromised, hacker would not be able to get the actual password of the users since the database is keeping only the encrypted password.

The bcrypt gem allows us to get hash of the password in a secure manner.

Let's add this gem to our Gemfile.

1gem 'bcrypt', '~> 3.1.13'
1bundle install

Adding credentials fields to User model

Now let's add two fields to User model. First field will store email and the second field will store password.

We will also put a unique index on column "email". When an index is declared unique, multiple table rows with the same value are not allowed. Note that two null values are not considered equal.

1bundle exec rails g migration AddEmailAndPasswordDigestToUser

Open db/migrate/add_email_and_password_digest_to_user.rb file and add following lines.

1class AddEmailAndPasswordDigestToUser < ActiveRecord::Migration[6.0]
2  def change
3    add_column :users, :email, :string, null: false, index: { unique: true }
4    add_column :users, :password_digest, :string, null: false
5  end

The Active Record uniqueness validation does not guarantee uniqueness at the database level. Here’s a scenario that explains why:

  1. Sam signs up for the sample app, with address sam@example.com.
  2. Sam accidentally clicks on “Submit” twice, sending two requests in quick succession.

The following sequence occurs: request 1 creates a user in memory that passes validation, request 2 does the same, request 1’s user gets saved, request 2’s user gets saved.

Result: two user records with the exact same email address, despite the uniqueness validation If the above sequence seems implausible, believe me, it isn’t. It can happen on any Rails website with significant traffic.

Luckily, the solution is straightforward to implement: we need to enforce uniqueness at the database level as well as at the model level. Our method is to create a database index on the email column, and then require that the index be unique.

Now let's run the migration.

1bundle exec rails db:migrate

We will get error like followings.

1SQLite3::ConstraintException: NOT NULL constraint failed: users.email

This is because in our database we already have users who do not have any email. In this migration we are telling Rails to put a unique constraint on the email column of users table. The database can't do that as long as we have records with null email.

So let's remove all users records. Time to fire up rails console.

1$ bundle exec rails console
2>> User.delete_all

Another reason for this error could be fixtures files added by the rails model generation command. Check if there are any fixtures files inside test/fixtures. If yes then remove them by running the following command.

1rm -rf test/fixtures/*

Now run migration one more time.

1bundle exec rails db:migrate

Securing password

As we discussed earlier password should not be stored in the plain text in the database. Rails provides has_secure_password to conveniently store password in an encrypted manner. Rails needs column password_digest in the User model to do its job.

Add following lines to User model.

1class User < ApplicationRecord
2  has_many :tasks, dependent: :destroy, foreign_key: :user_id
3  has_secure_password

has_secure_password line adds some convenience methods to the User class. These methods help in storing password in an encrypted form and also authenticate the plain text password with the stored encrypted password. From the Rails source code we can see that has_secure_password adds methods like password=, password_confirmation and authenticate_password.

Adding credential validations

Let's add validations to our newly added fields, email and password:

1class User < ApplicationRecord
2  has_many :tasks, dependent: :destroy, foreign_key: :user_id
3  has_secure_password
5  validates :password, length: { minimum: 6 }
6  validates :password_confirmation, presence: true, on: :create

has_secure_password auto-magically adds validations for presence of password on create and confirmation of password (by default it's password_confirmation attribute).

The confirmation validation creates a virtual attribute whose name is the name of the field that has to be confirmed with and "_confirmation" appended to it. Here it'd become "password_confirmation". We need to ensure the presence of confirmation field during creation.

Note that, here we are validating the presence of password_confirmation field only on active record create method. The reason is that, currently we need the password confirmation only when signing up for first time.

1class User < ApplicationRecord
2  VALID_EMAIL_REGEX = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i.freeze
4  has_many :tasks, dependent: :destroy, foreign_key: :user_id
5  has_secure_password
7  validates :email, presence: true,
8                    uniqueness: true,
9                    length: { maximum: 50 },
10                    format: { with: VALID_EMAIL_REGEX }
11  validates :password, presence: true, confirmation: true, length: { minimum: 6 }
12  validates :password_confirmation, presence: true, on: :create
14  before_save :to_lowercase
16  private
18    def to_lowercase
19      email.downcase!
20    end

The format validator ensures the field has specified format, here, VALID_EMAIL_REGEX. The before_save callback is called every time an object is saved. Before saving, the to_lowercase method makes all characters of email in lowercase.

Adding tests for credential validations in User model

Let's test whether a user will be created if no email is provided:

1def test_user_should_be_not_be_valid_and_saved_without_email
2  @user.email = ''
3  assert_not @user.valid?
5  @user.save
6  assert_equal ["Email can't be blank", 'Email is invalid'],
7                @user.errors.full_messages

Now we have to test whether all the emails are unique or not. For that we add the following test case:

1def test_user_should_not_be_valid_and_saved_if_email_not_unique
2  @user.save!
4  test_user = @user.dup
5  assert_not test_user.valid?
7  assert_equal ['Email has already been taken'],
8                test_user.errors.full_messages

The above tests validate presence and uniqueness of email. Let's add test to validate email length as we did for name field:

1def test_reject_email_of_invalid_length
2  @user.email = ('a' * 50) + '@test.com'
3  assert @user.invalid?

We can also check if the tests pass when we enter valid or invalid email values. Note that we always need to validate against multiple test data. This assures there are no loopholes in our tests. Here we can validate against multiple emails:

1def test_validation_should_accept_valid_addresses
2  valid_emails = %w[user@example.com USER@example.COM US-ER@example.org
3                    first.last@example.in user+one@example.ac.in]
5  valid_emails.each do |email|
6    @user.email = email
7    assert @user.valid?
8  end
11def test_validation_should_reject_invalid_addresses
12  invalid_emails = %w[user@example,com user_at_example.org user.name@example.
13                      @sam-sam.com sam@sam+exam.com fishy+#.com]
15  invalid_emails.each do |email|
16    @user.email = email
17    assert @user.invalid?
18  end

Validating password and password_confirmation:

1def test_user_should_not_be_saved_without_password
2  @user.password = nil
3  assert_not @user.save
4  assert_equal ["Password can't be blank"],
5                @user.errors.full_messages
8def test_user_should_not_be_saved_without_password_confirmation
9  @user.password_confirmation = nil
10  assert_not @user.save
11  assert_equal ["Password confirmation can't be blank"],
12                @user.errors.full_messages

Let's add test to validate unique auth token:

1def test_users_should_have_unique_auth_token
2  @user.save!
3  second_user = User.create!(name: 'Olive Sans', email: 'olive@example.com',
4                             password: 'welcome', password_confirmation: 'welcome')
6  assert_not_same @user.authentication_token,
7                  second_user.authentication_token

Now let's commit these changes:

1git add -A
2git commit -m "Added has_secure_password and validations to User model"
    to navigateEnterto select Escto close