[Ruby] Rails Tutorial 6th Edition Learning Summary Chapter 9

15 minute read

Overview

In order to make my knowledge more certain, this article has been developed by writing a Rails tutorial tutorial article. I am part of my study. In rare cases there may be ridiculous contents or wrong contents, so Please note. I would appreciate it if you could teach me…

Source Rails Tutorial 6th Edition

What to do in this chapter

  • Add a function that allows you to log in even if you restart the browser by remembering the login information of the user.

Remember me function

As mentioned in ↑, implement a function to retain login even if the browser is closed (Remember me) Create a topic branch and get started.

Remember tokens and encryption

Since future work and works will be difficult, I will confirm the knowledge in advance.

・What is a token? Something like a password that a computer uses. Passwords are created by humans and managed by humans, but tokens are created by computers and managed by computers.

· About persistent cookies and temporary sessions For the temporary session created in the previous chapter, the session method was used to create a session in which cookies expire when the browser ends. This time, we use the cookies method to create a session with an indefinite period (about 20 years to be exact). Unlike the session method, the cookies method does not protect information, and it is a target of attacks called session hijacking. By storing the user ID and the memory token as a set in cookies and storing the hashed token in DB Ensure security.

・Specific processing

  1. Store encrypted user ID and remember token in browser using cookies method
  2. Store the hashed memory token (memory digest) in the DB at the same time.
  3. At the next access, compare the token of the time-limited cookies saved in the browser with the memory digest saved in DB. Perform login process automatically.

I checked the contents roughly, so Immediately add the memory digest (remember_digest) to the DB. rails g migration add_remember_digest_to_users remember_digest:string As explained previously, adding to_users at the end of the file name will allow you to arbitrarily add a column to the users table.

Remember_digest does not need to be indexed because it is not user-readable. Therefore, it will be migrated as it is.

What to use to create a memory token Long, random strings are preferred. Since the ```urlsafe_base64

method of the SecureRandom module matches the purpose, I will use it.


This method is a method that returns a random string of length 22 using 64 characters.
A memory token will be automatically generated using this method.

```irb
>> SecureRandom.urlsafe_base64
=> "Rr2i4cNWOwhtDeVA4bnT2g"
>> SecureRandom.urlsafe_base64
=> "pQ86_IsKILLv4AxAnx9iHA"

Like the password, the token can be duplicated with other users⁻, but by using a unique token Unless both the user ID and token are stolen, it will not lead to session hijacking.

Define a new token creation (generation) method in the user model.

  def User.new_token
    SecureRandom.urlsafe_base64
  end

This method is also defined as a class method because no user object is required.

Next, create a remember method. This method saves the stored digest corresponding to the token in DB. Remember_digest exists in DB, but remember_token does not exist. I want to save only the digest in DB, but I want to save the digest for the token associated with the user object. I also want to access token attributes. In other words, the token is required as a virtual attribute, just like the password. Has_secure_password generated automatically when implementing the password, but this time Create remember_token using ```attr_accessor

.




#### **`user.rb`**
```rb

class User <ApplicationRecord
  attr_accessor :remember_token
  
  # before_save {self.email.downcase!}
  # has_secure_password
  # VALID_EMAIL_REGEX = /\A[\w+\-.][email protected][a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  # validates :name, presence: true, length:{maximum: 50}
  # validates :email, presence: true, length:{maximum: 255},
  # format: {with: VALID_EMAIL_REGEX},uniqueness: true
  # validates :password, presence: true, length:{minimum: 6}
  
  #def User.digest(string)
  # cost = ActiveModel::SecurePassword.min_cost ?BCrypt::Engine::MIN_COST:
  #BCrypt::Engine::cost
  # BCrypt::Password.create(string, cost: cost)
  # end
  
  #def User.new_token
  #SecureRandom.urlsafe_base64
  # end
  
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest,User.digest(remember_token))
  end
end

The first line of the remember method self.remember_token = User.new_token is Required here because a local variable called remember_token will be created if self is not written.

Here, since the password cannot be accessed, update_attribute is used to pass validation.

Exercise
  1. Moves well. remember_token is a randomly generated 22-character string You can see that remember_digest is a hashed string of them.
>> user.remember
   (0.1ms) begin transaction
  User Update (2.4ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" =? WHERE "users"."id" =? [["Updated_at", "2020-06-17 14:30:27.202627" ], ["remember_digest", "$2a$12$u8cnBDCQX85e9gBHbQWWNeJq.mxKq8hhpt/FSYMtm2rI2ljWIhRLi"], ["id", 1]]
   (6.1ms) commit transaction
=> true
>> user.remember_token
=> "lZaXgeF42y5XeP-EEPzstw"
>> user.remember_digest
=> "$2a$12$u8cnBDCQX85e9gBHbQWWNeJq.mxKq8hhpt/FSYMtm2rI2ljWIhRLi"
>>
  1. Both work the same If you use ```class « self

    , everything up to end is defined as a class method.

    ```

Note that the self keyword here represents the User class itself, not the instance object.

Maintaining login status

Use the ```cookies

method to store persistent cookies.


It can be used as a hash similar to session.

cookies have value and expires

```rb
cookies[:remember_token] = {value: remember_token, expires: 20.years.from_now.utc}

By doing so, you can save the value of remember_token with an expiration date of 20 years in cookies[:remember_token]. Also, the expiration date of 20 years is often used, so a special method has been added to Rails

cookies.permanent[:remember_token] = remember_token

Will have the same effect.

Also, the user ID is also saved in the permanent cookies, but if you save it as it is, the ID will be saved Since the format in which cookies are stored will be revealed to the attacker, Encrypt. Use signed cookies for encryption.

cookies.signed[:user_id] = user.id Now you can safely encrypt and save.

Of course, the user ID also needs to be saved as persistent cookies, so use the permanent method by connecting it. cookies.permanent.signed[:user_id] = user.id

By putting the user ID and the memory token as a set in cookies like this Cannot log in when user logs out (because the DB digest is deleted)

Finally it is a method to compare the token stored in the browser and the digest of DB Part of source code of secure_password

BCrypt::Password.new(remember_digest) == remember_token Use code like this: This code directly compares remember_digest and remember_token. Actually, the == operator is redefined in Bcrypt, and this code is

Is operating.
Use this to define a ```authenticated?
#### **` method that compares the remember digest with the remember token.`**

```rbdef authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end

Remember_digest here is the same as self.remember_digest.
Compare the memory digest of DB and the memory token passed in the argument and return true if correct

Immediately add remember process to the login process part of sessions_controller.

```rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user&.authenticate(params[:session][:password])
      log_in(user)
      remember user
      redirect_to user
    else
      flash.now[:danger] = "Invalid email/password combination"
      render'new'
    end
  end

Here we use the remember helper method. (Not defined yet)

↓ remember helper method

sessions_helper.rb


  def remember(user)
    user.remember
    cookies.signed.permanent[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

It is difficult to understand, so I will add it. In the remember method defined in the User model

user.rb


  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest,User.digest(remember_token))
  end

Generate a remember token and remember digest for the user object.

In the remember method defined in sessions_helper

  1. Invoke the remember method of User model and generate token and digest.
  2. User ID is encrypted and stored in cookies
  3. Save the token generated in 1 in cookies

Flow of. Note that the method name is covered.

Now that user information can be safely stored in cookies, see the login status The ```current_user

method I used to dynamically change the layout only works for temporary sessions.


Correct because it is not supported.

```rb
  def current_user #returns the currently logged in user⁻ object
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user &. authenticated?(cookies[remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  • Reducing code duplication by using a local variable called user_id. ・Permanent cookies are processed and login processing is performed at the same time when the browser is opened for the first time. Users are stored in @current_user until the browser is closed.

Currently there is no way to delete the logout process (persistent cookies) I can’t log out. (Because the existing logout action only deletes the temporary session, you can retrieve the information from the persistent cookies. I cannot log out because I am logged in automatically. )

Exercise
  1. Yes. image.png

  2. Move.

>> user = User.first
   (1.1ms) SELECT sqlite_version(*)
  User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?[["LIMIT", 1]]
=> #<User id: 1, name: "take", email: "[email protected]", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-18 15:18:53", password_digest: [FILTERED], remember_digest: "$2a$12$tAZFCVr39lkPONLS4/7zneYgOE5pcYDM2kX6F1yKew2...">
>> user.remember
   (0.1ms) begin transaction
  User Update (2.8ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" =? WHERE "users"."id" =? [["Updated_at", "2020-06-18 15:23:21.357804" ], ["remember_digest", "$2a$12$h3K3aZSBmXB7wGkNdsBrS.2/UaawMQ199DGMvTDU8upvvOKCzbeba"], ["id", 1]]
   (10.3ms) commit transaction
=> true
>> user.authenticated?(user.remember_token)
=> true

Forget user

Currently, I haven’t deleted my persistent cookies, so I can’t log out. Define a ```forget

method to solve this problem.


This method makes the memory digest nil.
Furthermore, by defining the ```forget``` method in sessions_helper,
Here, we also delete the user ID and memorized token stored in cookies.

```rb
  def forget(user) # delete persistent session, reset remember digest
    user.forget
    cookies.delete[:user_id]
    cookies.delete[:remember_token]
  end
  def log_out
    forget(current_user)
    session.delete(:user_id)
    @current_user = nil
  end

The flow of logout processing is briefly reviewed.

  1. Set the memory digest saved in the user object to nil (Forget method of User model)
  2. Delete user ID and remember token of cookies (forget method of sessions_helper)
  3. Delete the user ID of the temporary session
  4. Set the current user (currently logged in user) to nil.
Exercise
  1. It has been deleted. (Execution screen omitted) In Chrome, there is still a temporary session as before, but on the operation of the application no problem.

Two inconspicuous bugs

At the moment there are two bugs left. It is quite troublesome, so I will explain it in detail.

First bug You logged in on multiple tabs, logged out on tab 1, and then logged out on tab 2. After logging out using the log_out method in tab 1, current_user is nil. Attempting to log out again in this state fails because the cookie to be deleted cannot be found.

Second bug When logged in with another browser (Chrome, Firefox, etc.).

  1. When you log out with Firefox, remember_digest becomes nil.
  2. Closing Chrome deletes the temporary session but leaves cookies so you can find the user from their user ID.
  3. Remember_digest to compare with the user.authenticated? method has already been deleted in firefox There is no comparison target and an error occurs.

To fix this bug, first write a test to catch the bug Write the code to fix it.

delete logout_path Re-logout twice by inserting this again after the logout process of the login test.

In order to pass this test You should do the logout process only during login.

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end

Regarding the second bug, it is difficult to reproduce a different browser environment in the test, so Only test about remember_digest of User model. Specifically, test that false is returned when remember_digest is nil.

  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end

Improve the authenticated? method to pass the test

  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

When remember_digest is nil, it immediately returns false with the return keyword and terminates the process.

This fixes two bugs.

Exercise
  1. An error occurs. (The execution screen is omitted.)
  2. This also gives an error (Edge and Chrome)
  3. Confirmed.

###[Remember me] check box Next, implement a check box that is indispensable for the Remember me function (a function to remember only when checked)

      <%= f.label :remember_me, class: "checkbox inline" do %><%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

For the reason to place it inside the label https://html-coding.co.jp/annex/dictionary/html/label/ This site is easy to understand In other words, clicking anywhere on the person specified in the label has the same effect as pressing the check box.

Once you’ve shaped it with CSS, you’re ready to go. With the check box, params[:session][:remember_me] is set to 1 or 0. All you have to do is remember it when it was 1.

Implemented using the ternary operator

  params[:session][:remember_me] == '1'? remember(user): forget(user)

Just replace the remember user line with this By the way, the ternary operator is

  Condition statement ?Process when true: Process when false

Can be written in this format. By the way, all the values of params are recorded as strings, so you must enclose 1 in the conditional statement with ``. Please be careful because false will always be executed and you will not be able to remember

Exercise

1.↑Although I wrote a note, the condition of params must be ‘1’ to work. Hopefully the values are stored in cookies It works well.

2.

>> hungry = true
=> true
>> hungry ?puts("I'm hungry now") :puts("I'm not hungry now")
I'm hungry now
=> nil

###[Remember me] test Now that I have implemented Rememberme, I will create a test.

Test the [Remember me] box

params[:session][:remember_me] == '1'? remember(user): forget(user) implemented by the last ternary operator That means 1 (true) 0 (false) for people who are touching the program params[:session][:remember_me]? remember(user): forget(user) The check box only returns 1 and 0. In Ruby, 1 and 0 are not true and false, but both are treated as true. You have to write a test that can catch these mistakes.

A login is required to remember the user. Until now, I used to use post method to send params hash. Since it is truly troublesome to do it every time, define a method for login. Define as log_in_as method to prevent confusion with log_in method.

test_helper


class ActiveSupport::TestCase
  # Run tests in parallel with specified workers
  parallelize(workers: :number_of_processors)

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all
  include ApplicationHelper
  # Add more helper methods to be used by all tests here...
  def is_logged_in?
    !session[:user_id].nil?
  end
  
  def log_in_as(user)
    session[:user_id] = user.id
  end
  
  
end

class ActionDispatch::IntegrationTest
  def log_in_as(user, password:'password', remember_me: '1')
    post login_path, params:{ session: {email: user.email,
                                                password: password,
                                                remember_me: remember_me}}
  end
end

The log_in_as method is defined twice in ActionDispatch::IntegrationTest and ActiveSupport::TestCase separately. You can’t use the ```session

method in integration tests.


So the integration test logs in using a post request instead.

If you want to log in both integration test and unit test by making both tests the same name, do not mind anything and log_in_as method
You can call.

Now that we have defined the log_in_as method, implement the Remember_me test.

```rb
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_empty cookies[:remember_token]
  end
  
  test "login without remembering" do
    log_in_as(@user, remember_me: '1')
    delete logout_path
    log_in_as(@user, remember_me: '0')
    assert_empty cookies[:remember_token]
  end

log_in_as(@user, remember_me:'1') Since the default value is set, it is originally unnecessary, but the remember_me attribute is also entered for easy comparison.

Exercise

In the integration test of 1.↑, I could not access the virtual attribute remember_token, so I was only testing that cookies were not empty. By using the ```assigns

method, you can get the instance variable of the action accessed immediately before.


In the above test example, we access the create action of sessions_controller in the ```log_in_as
#### **` method.`**

You can read the value of the instance variable defined by the create action using a symbol. In particular Currently, the create action uses a local variable called user, so add @ to this and call it @user. The assigns method can be read by changing it to an instance variable. After that, @user can be read by calling ```assigns(:user)

in the test.




#### **`users_login_test.rb`**
```rb

  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal cookies[:remember_token] ,assigns(:user).remember_token
  end

sessions_controller.rb


  def create
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user&.authenticate(params[:session][:password])
      log_in(@user)
      params[:session][:remember_me] == '1'? remember(@user): forget(@user)
      redirect_to @user
    else
      flash.now[:danger] = "Invalid email/password combination"
      render'new'
    end
  end

Test ####[Remember me] Login process and session related helper methods have been implemented in sessions_helper, No tests have been done on the branching of the ```current_user

method.


Even if you substitute an appropriate character string that has nothing to do with the evidence, the test will pass.

GREEN test↓


#### **`sessions_helper.rb`**
```rb

  def current_user #returns the currently logged in user⁻ object
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      I haven't tested it so Japanese is acceptable.
      user = User.find_by(id: user_id)
      if user &.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end

This is bad, so create a test file like ```sessions_helper

.




#### **`sessions_helper_test.rb`**
```rb

require'test_helper'
class SessionsHelperTest <ActionView::TestCase
  def setup
    @user = users(:michael)
    remember(@user)
  end
  
  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end
  
  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end

The first test checks if the user you remembered is the same as the current_user, and that you are logged in. By doing this, the test can confirm whether the processing of the contents is working when the user ID exists in the cookies.In the second test, remember_digest is rewritten so that it does not correspond to remember_token recorded by ```remember

method.


Current_user returns nil as expected, i.e. the ```authenticated?``` method
Testing for proper operation.

Also, as a supplement, the ```assert_equal
#### **` method works even if the first and second arguments are swapped.`**

Note that you have to write the expected value in the first argument and the actual value in the second argument. If you do not write it like this, the log display will not mesh when an error occurs.

And of course the test fails at this stage.

The test passes by deleting the irrelevant sentence that I put in. Now you can test any branch of current_user so you can catch regression bugs.

Exercise
  1. Even if the memorized token and the memorized digest do not correspond correctly, the if statement will pass even if the user exists. The return value is no longer nil. In other words, the test also fails.
 FAIL["test_current_user_returns_nil_when_remember_digest_is_wrong", #<Minitest::Reporters::Suite:0x000055b13fd67928 @name="SessionsHelperTest">, 1.4066297989993473]
 test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (1.41s)
        Expected #<User id: 762146111, name: "Michael Example", email: "[email protected]", created_at: "2020-06-20 15:38:57", updated_at: "2020-06-20 15: 38:58", password_digest: [FILTERED], remember_digest: "$2a$04$uoeG1eJEySynSb.wI.vyOewe9s9TJsSoI9vtXNYJxrv..."> to be nil.
        test/helpers/sessions_helper_test.rb:15:in `block in <class:SessionsHelperTest>'

↑ It is expected that the return value of current_user will be nil, but that the user object has returned It is output as an error.

To the previous chapter

To the next chapter