Rails Tutorial 6th Edition Learning Summary Chapter 9

Overview

This article deepens my understanding by writing a Rails tutorial commentary article to further solidify my knowledge It is part of my study. In rare cases, ridiculous content or incorrect content may be written. Please note. I would appreciate it if you could tell me implicitly ...

Source Rails Tutorial 6th Edition

What to do in this chapter

-Add a function to store login information at the user's discretion and log in even if the browser is restarted.

Remember me function

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

Memory token and encryption

Since future work and creation are quite difficult, check the knowledge ahead of time.

・ What is a token? It's like a password used by a computer. Passwords are created by humans and managed by humans, while tokens are created by computers and managed by computers.

· Persistent cookies and temporary sessions For the temporary session created in the previous chapter, the session method was used to create a session in cookies whose expiration date is when the browser is closed. This time, we will use the cookies method to create a session with an infinite expiration date (to be exact, about 20 years). Unlike the session method, the cookies method does not protect information and is the target of an attack called session hijacking. By saving the user ID and memory token as a set in cookies and saving the hashed token in the DB Ensure security.

・ What kind of processing should be used to implement it?

  1. Save the user ID and storage token encrypted using the cookies method in the browser
  2. Store the hashed storage token (memory digest) in the DB at the same time.
  3. The next time you access, compare the token of the time-limited cookies stored in the browser with the memory digest stored in the DB. Login process is performed automatically.

I checked the contents roughly Immediately add a memory digest (remember_digest) to the DB.

string


 As explained before, if you add a column to the users table by adding to_users to the end of the file name, it will be recognized without permission.

 Since remember_digest is not readable by the user, there is no need to add an index.
 Therefore, it will be migrated as it is.

 What to use to create a memory token
 A long, random string is preferred.
 Since the ```urlsafe_base64``` method of the SecureRandom module matches the purpose, we will use it.
 This method uses 64 kinds of characters and returns a random character string of length 22.
 The storage token will be automatically generated using this method.

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

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

Define a method to create (generate) a new token in the user model.

  def User.new_token
    SecureRandom.urlsafe_base64
  end

This method also does not require a user object, so it is defined as a class method.

Next, create the remember method. This method saves the storage digest corresponding to the token in the DB. Remember_digest exists in DB, but remember_token does not exist. I only want to save the digest in the DB, but I want to save the digest for the token associated with the user object. I also want to access the token attribute. In other words, a token is required as a virtual attribute as in the case of a password. Has_secure_password was automatically generated when the password was implemented, but this time attr_accessorRemember using_Create a token.

user.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

First line of remember method

self.remember_token = User.new_token is


 If you do not write self, a local variable called remember_token will be created, so it is required here.

 Since the password is inaccessible here, update_attribute is used to bypass validation.

##### Exercise
 1. Move firmly.
 remember_token is a 22-character randomly generated string
 You can see that remember_digest is a hashed string of them.

```irb
>> 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 class << selfIf you use, everything up to the end is defined as a class method. Note that the self keyword here represents the User class itself, not the instance object.

Keeping logged in

Use the `` `cookies``` method to save to persistent cookies. It can be used as a hash similar to session.

cookies have a value and expires

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

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

cookies.permanent[:remember_token] = remember_token

It has the same effect.

Also, the user ID is saved in persistent cookies, but if you save it as it is, the ID will be saved as it is, Because the attacker will be confused about the format in which cookies are stored. Encrypt. Use signed cookies for encryption.

cookies.signed[:user_id] = user.id


 You can now securely encrypt and save it.

 Of course, the user ID also needs to be saved as persistent cookies, so use it by connecting the permanent method.

#### **`cookies.permanent.signed[:user_id] = user.id`**

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

Finally, how to compare the token stored in the browser with the digest of the DB Part of the 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.
 In fact, Bcrypt has redefined the == operator, and this code

#### **`BCrypt::Password.new(remember_digest).is_password?(remember_token)`**

It is operating. Use this to define a ```authenticated? `` `Method that compares a memory digest with a memory token.

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

Remember_digest here is the same as self.remember_digest. Compares the DB memory digest with the memory token passed as an argument and returns true if correct

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

  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

Supplement because it is difficult to understand. With 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 memory token and a memory digest for a user object.

With the remember method defined in sessions_helper

  1. Call the remember method of the User model to generate a token and digest.
  2. Encrypt and save user ID in cookies
  3. Save the token generated in 1 in cookies

Flow of. Note that the method name is covered.

Now you can safely store your user information in cookies, but look at your login status The `` `current_user``` method used to dynamically change the layout is only for temporary sessions It is not supported, so fix it.

  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

-The code duplication is reduced by using a local variable called user_id. -Because persistent cookies are processed at the first execution when the browser is opened, and login processing is also performed at the same time. The user is stored in @current_user until the browser is closed.

At the moment there is no way to delete the logout process (persistent cookies) I can't log out. (The existing logout action only deletes the temporary session, so retrieve the information from persistent cookies I cannot log out because I log in automatically. )

Exercise
  1. Yes. image.png

  2. It works.

>> 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 the user

You cannot currently log out because you have not deleted persistent cookies. Define a forget method to solve this problem. Set the memory digest to nil with this method. You can also define the `` `forget``` method in sessions_helper This also deletes the user ID and memory token stored in cookies.

  def forget(user) #Delete persistent session / reset memory 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

Let's review the flow of logout processing.

  1. Set the storage digest saved in the user object to nil (user model forget method)
  2. Delete the user ID and memory 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 is omitted) In Chrome, a temporary session remains as before, but in terms of application operation no problem.

Two unobtrusive bugs

At the moment there are two bugs left. It's quite troublesome, so I'll explain it in detail.

First bug When you are 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. If you try to log out again in this state, it will fail because the cookie to be deleted cannot be found.

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

  1. When I log out in Firefox, remember_digest becomes nil.
  2. When you close Chrome, the temporary session is deleted but cookies remain, so you can find the user by user ID.
  3. Because remember_digest to compare with ```user.authenticated? `` `method has already been deleted on Firefox side There is no comparison target and an error occurs.

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

delete logout_path Reproduce the logout twice by inserting this again after the login test logout process.

To pass this test You only have to log out while you are logged in.

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end

Regarding the second bug, it is difficult to reproduce different browser environments in the test, so Only test for remember_digest of User model. Specifically, it tests that it returns false when remember_digest is nil.

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

Improve authenticated? Method to pass test

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

If remember_digest is nil, immediately return false with the return keyword and end 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 checkbox

Next, implement a check box that is indispensable for the Remember me function (a function that remembers 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 for placing it inside the label, see https://html-coding.co.jp/annex/dictionary/html/label/ This site is easy to understand In other words, clicking anywhere on the label can behave as if you pressed the checkbox.

Once you've shaped it with CSS, you're ready to go. Since 1 or 0 is now entered in params [: session] [: remember_me] in the check box. You should remember it when it is 1.

When implemented using the ternary operator

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

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

Conditional statement?Processing when true:Processing when false

Can be written in the format. By the way, all the numerical values of params are recorded as character strings, so 1 in the conditional statement must be enclosed in''. Note that false minutes will always be executed and you will not be able to remember.

Exercise

  1. ↑ But I wrote a note, but it doesn't work unless the condition of params is '1'. Hopefully the value is stored in cookies It works well.

>> 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 we have implemented Remember me, we will create tests as well.

Test the Remember me box

`params [: session] [: remember_me] == '1'? Remember (user): forget (user)` implemented with the previous ternary operator The part is 1 (true) 0 (false) for the person who is touching the program.

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


 However, the checkbox returns 1 and 0.
 In Ruby, 1 and 0 are not boolean values and both are treated as true, so it would be a mistake to write this way.
 You have to write a test that can catch such mistakes.

 You need to log in to remember the user. Until now, I used the post method to send the params hash one by one.
 Since it is troublesome to do it every time, define a method for login.
 Defined as log_in_as method to prevent confusion with log_in method.


#### **`test_helper`**
```rb

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 separately in ActionDispatch :: IntegrationTest and ActiveSupport :: TestCase. You can't use the session method in integration testing. So in integration testing, I'm logging in using a post request instead.

By giving both tests the same name, you can log in to both integration tests and unit tests without worrying about the log_in_as method. Just call.

Since we have defined the log_in_as method, we will implement the Remember_me test.

  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

'1')

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]

I have implemented login processing and session related helper methods in sessions_helper current_userNot tested for method branching. Substituting an appropriate string that has nothing to do with the evidence will pass the test.

GREEN test ↓

sessions_helper.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])
Japanese is also allowed because I haven't tested it.
      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


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 makes sure that the remembered user and current_user are the same, and that they are logged in. By doing this, the test can confirm that the processing of the contents is working when the user ID exists in cookies.

In the second test, by rewriting remember_digest, it does not correspond to remember_token recorded by the `remember``` method. The current_user returns nil as expected, that is, the ```authenticated? `Method I'm testing that it's working properly.

Also, as a supplement, the assert_equal method works even if the first argument and the second argument are exchanged. Note that you must 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 engage when an error occurs.

And, of course, the test does not pass at this stage.

The test passes by deleting the completely irrelevant sentences that you put in. Now that you can test any branch of current_user, you can catch regression bugs.

Exercise
  1. Even if the memory token and the memory digest do not correspond correctly, the if statement will be passed just because 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>'

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

To the previous chapter

To the next chapter

Recommended Posts

Rails Tutorial 6th Edition Learning Summary Chapter 10
Rails Tutorial 6th Edition Learning Summary Chapter 7
Rails Tutorial 6th Edition Learning Summary Chapter 4
Rails Tutorial 6th Edition Learning Summary Chapter 9
Rails Tutorial 6th Edition Learning Summary Chapter 6
Rails Tutorial 6th Edition Learning Summary Chapter 5
Rails Tutorial 6th Edition Learning Summary Chapter 2
Rails Tutorial 6th Edition Learning Summary Chapter 3
Rails Tutorial 6th Edition Learning Summary Chapter 8
Rails Tutorial (4th Edition) Memo Chapter 6
Rails Tutorial Chapter 3 Learning
Rails Tutorial Chapter 4 Learning
Rails Tutorial Chapter 1 Learning
Rails Tutorial Chapter 2 Learning
Rails Tutorial 4th Edition: Chapter 1 From Zero to Deployment
rails tutorial Chapter 6
rails tutorial Chapter 1
rails tutorial Chapter 7
rails tutorial Chapter 5
rails tutorial Chapter 10
rails tutorial Chapter 9
rails tutorial Chapter 8
Rails Tutorial Chapter 0: Preliminary Basic Knowledge Learning 5
[Rails] Learning with Rails tutorial
Rails Tutorial Memorandum (Chapter 3, 3.1)
[Rails Tutorial Chapter 4] Rails-flavored Ruby
[Rails Tutorial Chapter 5] Create a layout
Chewing Rails Tutorial [Chapter 2 Toy Application]
rails tutorial
rails tutorial
rails tutorial
rails tutorial
rails tutorial
rails tutorial
rails tutorial
A summary of only Rails tutorial setup related
Rails tutorial test
Rails tutorial memorandum 1
Rails Tutorial Chapter 1 From Zero to Deployment [Try]
Rails learning day 3
Rails tutorial memorandum 2
Rails learning day 4
Rails 6.0 Routing Summary
Rails learning day 2
Chewing Rails Tutorial [Chapter 3 Creating Almost Static Pages]
rails db: 〇〇 Summary
[Beginner] Rails Tutorial
[Learning Memo] Metaprogramming Ruby 2nd Edition: Chapter 3: Methods
rails learning day 1
Ruby on Rails5 Quick Learning Practice Guide 5.2 Compatible Chapter2
Ruby on Rails5 Quick Learning Practice Guide 5.2 Compatible Chapter3
Resolve ActiveRecord :: NoDatabaseError when doing rails test (Rails tutorial Chapter 3)
[Rails] Migration command summary
Chapter 4 Rails Flavored Ruby
Rails Tutorial cheat sheet
Rails learning day 2 part 2
[Rails] rails db command summary
Rails learning day 1 part 3
Rails learning day 3 part 2
Rails learning Day 1 Part 2
rails tutorial fighting notes Ⅲ