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
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
6end
The Active Record uniqueness validation does not guarantee uniqueness at the database level. Here’s a scenario that explains why:
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 just 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
Now run migration one more time.
1bundle exec rails db:migrate
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
4end
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
.
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
4
5 validates :password, presence: true, confirmation: true, length: { minimum: 6 }
6 validates :password_confirmation, presence: true, on: :create
7end
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".
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.
1class User < ApplicationRecord
2 VALID_EMAIL_REGEX = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i.freeze
3
4 has_many :tasks, dependent: :destroy, foreign_key: :user_id
5 has_secure_password
6
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
13
14 before_save :to_lowercase
15
16 private
17
18 def to_lowercase
19 email.downcase!
20 end
21end
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.
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?
4
5 @user.save
6 assert_equal ["Email can't be blank", 'Email is invalid'],
7 @user.errors.full_messages
8end
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!
3
4 test_user = @user.dup
5 assert_not test_user.valid?
6
7 assert_equal ['Email has already been taken'],
8 test_user.errors.full_messages
9end
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?
4end
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]
4
5 valid_emails.each do |email|
6 @user.email = email
7 assert @user.valid?
8 end
9end
10
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]
14
15 invalid_emails.each do |email|
16 @user.email = email
17 assert @user.invalid?
18 end
19end
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
6end
7
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
13end
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')
5
6 assert_not_same @user.authentication_token,
7 second_user.authentication_token
8end
Now let's commit these changes:
1git add -A
2git commit -m "Added has_secure_password and validations to User model"