[RUBY] I changed the way Rails tutorials run: Rails Tutorial Notes-Chapter 9

Currently self-taught, the first lap of the Rails tutorial If you want to challenge on the second lap I want to try Test Driven Development (TDD) I decided to list the requirements that should be implemented in the theme of each chapter

As a task flow that I imagined in the second week Requirement definition (leave here in the first week)> Write a test (connect here in the second week)> Implementation (If it is far from reality, please point it out early)

Goal: To be able to stay logged in for a longer period of time and more securely

Awareness of this chapter

--Cookies stored in the browser are used to maintain login information. --I was able to experience the scope of Rails variables (local variables and instance variables) --ʻAssigns (: user) makes it possible to access the previous instance variable @user. --Dare to include raise in your code to raise an exception so you can see if it's included in your test --ʻAssert_equal is written as <expected>, <actual> --There is a way to display the maintenance page if Heroku is temporarily inaccessible heroku maintenance: on (off)

Not related to the content of this chapter Template out the output to improve the quality of learning Overwhelmingly increased the amount (iuput: output = about 2: 8) The speed of progress has dropped significantly, but I feel that the quality of learning has improved.

Difficult to balance time efficiency and learning effect

Introduced markdown editor for mac to improve time efficiency as much as possible

Requirements

Generate a cookie that is retained when you log in and close your browser

--Save user ID and memory token in cookie --Make it a persistent cookie --User ID is encrypted and saved (signature) --Use a random character string as a memory token --The token is converted to a hash value and then saved in the database (digest memory). --Allows you to choose to keep your login using checkboxes

Keep login as user on DB matching cookie when browser is closed (current_user = nil)

--If not current_user = nil, the following processing is unnecessary. --Search the DB with the user ID saved in the cookie and extract the matching user (user) --Confirm that the hashed cookie memory token matches the user's digest memory (DB) --Log in as user (no longer current_user = nil)

Successful complete logout

--When you log out, session is nil and storage token (cookie) and digest storage (DB) are nil. --When you log out, current_user becomes nil --Redirect to root_url after logout (implemented)

Note: Chapter 9 Evolving Login Mechanism

9.1 Remember me function

9.1.1 Memory token and encryption

Risk of cookie exposure and what to do

Extract cookies directly from network packets that pass through a poorly managed network with a special software called packet sniffer > Countermeasure: SSL (supported)

Extract the storage token from the database > Workaround: Hash the token stored in the DB

Steal access by directly operating the computer or smartphone on which the user is logged in > Workaround: Change token when logged out (Rails Tutorial 6th Edition)

Added remember_digest attribute to User model

$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate

I want to be able to handle the remember_token attribute in the User model This attribute is not saved in the DB > Use ʻattr_accessor`

Why you need attr_accessor and why you don't (Thanks to Rails): Rails Tutorial Memorandum-Chapter 9

Define ʻUser.new_tokenin User model (return memory token) SecureRandom.urlsafe_base64` is suitable for generating memory tokens

$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"
  def User.new_token
    SecureRandom.urlsafe_base64
  end

Hash the storage token and save it in the DB Define remember in User model You can use ʻUser.digest (string)` for hashing

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

self. is required

I also want to have remember executed when I log in It's after this

9.1.2 Keeping logged in

I want to save the user ID in the browser Signed with signed cookies

cookies.signed[:user_id] = user.id

Decrypted with cookies.signed [: user_id]

Cookie persistence is possible with permanent In the method chain

cookies.permanent.signed[:user_id] = user.id

Define ʻauthenticated?` in User model The behavior is the same as the password, but I can imagine it, but the partial understanding of BCrypt ... is insufficient.

#Returns true if the passed token matches the digest
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

The argument remember_token is a local variable

Change the behavior when logging in Added remember (user) This is different from the remember method of the User model (taking arguments)

app/controllers/sessions_controller.rb


  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && 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

remember (user) is defined as a helper Call when you log in Generates a memory token, saves it in a cookie, and saves a digest token in a DB. (The significance of using different helpers and the priority of methods with the same name are not fully understood)

app/helpers/sessions_helper.rb


  #Make a user's session persistent
  def remember(user)
    user.remember#User model method (generation of storage token, DB storage of digest token)
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

current_user is not just session Be maintained by cookies

app/helpers/sessions_helper.rb


  #Returns the user corresponding to the memory token cookie
  def current_user
    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 && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

You can omit repetition with (user_id = session [: user_id]) = is not a logical operation, assignment

At this point rails test is (RED)

FAIL["test_login_with_valid_information_followed_by_logout", #<Minitest::Reporters::Suite:0x0000556848d6b040 @name="UsersLoginTest">, 1.7997455329999923]
 test_login_with_valid_information_followed_by_logout#UsersLoginTest (1.80s)
        Expected at least 1 element matching "a[href="/login"]", found 0..
        Expected 0 to be >= 1.
        test/integration/users_login_test.rb:36:in `block in <class:UsersLoginTest>'

I don't see the link to login In other words, it seems that you have not logged out (Expected to maintain current_user by cookie)

9.1.3 Forget user

DB remember_digest when logging out Delete browser cookies (update with nil)

First, define forget to operate the DB

app/models/user.rb


 #Discard user login information
  def forget
    update_attribute(:remember_digest, nil)
  end

Next, define the helper method forget (user) Nil : remember_digest on the DB with ʻuser.forgetearlier Browser cookies nil withcookies.delete`

/sample_app/app/helpers/sessions_helper.rb


 def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

Call this helper method forget (user) with the same helper method log_out

/sample_app/app/helpers/sessions_helper.rb


 def log_out
    forget(current_user)
    session.delete(:user_id)
    @current_user = nil
  end

Now log_out can empty both session and cookie Full logout possible

rails test is (GREEN)

9.1.4 Two unobtrusive bugs

Resolve bugs that occur when using multiple tabs and browsers

First, if you open multiple tabs while logged in at the same time, Problems caused by being able to step on Logout (delete logout_path) multiple times

Since current_user = nil is set in the first delete logout_path If you request delete logout_path again, the controller will call the log_out method again. When I try to execute forget (current_user), I get an error because current_user = nil

NoMethodError: undefined method `forget' for nil:NilClass
app/helpers/sessions_helper.rb:36:in `forget'
app/helpers/sessions_helper.rb:24:in `log_out'
app/controllers/sessions_controller.rb:20:in `destroy'

To be able to detect this in a test Very simple considering that you should request delete logout_path again

test/integration/users_login_test.rb


require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "login with valid information followed by logout" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in?
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    #↓ Here again delete log out_Request path
    delete logout_path
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end
end

When you run the test (RED) Confirm that the bug can be reproduced successfully

When you are not logged in For the delete logout_path request The log_out method should not be called

You can use the logged_in method to confirm your login

  def logged_in?
    !current_user.nil?
  end

app/controllers/sessions_controller.rb


  def destroy
    log_out if logged_in?
    redirect_to root_url
  end

Becomes log_out if logged_in? Is a fashionable way of writing Can be replaced with ʻif ... end`

[Ruby] Abuse is strictly prohibited! ?? Cases where writing with a postfix if makes it harder to read This person has been pointed out like this from the viewpoint of readability and would like to refer to it.

In this state, rails test is (GREEN)

Next, A, B, two browsers are open, log out with one browser B (DB remember_digest is nil), Then close browser A (browser A's session [: user_id] is nil) Problems caused by leaving only cookies stored in browser A

If you reopen Browser A in this state, cookies will remain.

python


def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id]) #Becomes true here
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])#error
      log_in user
      @current_user = user
    end
  end
end

ʻIf user && user.authenticated? (cookies [: remember_token])is executed Becauseremember_digest is set to nil` due to the behavior of another browser

python


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

Returns an exception due to inconsistency with the contents of the cookie

BCrypt::Errors::InvalidHash: invalid hash

To be able to detect this in the test The situation of browser A should be reproduced with the object of User model. In other words, prepare a model with remember_digest = nil

test/models/user_test.rb


require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "[email protected]",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end

The @user created by the setup method is just that, so you can use it Currently remember_digest = nil @ user.authenticated? ('') Returns an exception and the test is (RED)

If remember_digest = nil I want @ user.authenticated? ('') to return false

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

Should be With Progate, I often used it at the end of the If statement, so I don't notice it. return exits the method there and returns the value So no less is executed

Now the test is (GREEN)

9.2 Remember me check box

Checkboxes can be inserted with the helper method

html:/sample_app/app/views/sessions/new.html.erb


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

Add CSS

app/assets/stylesheets/custom.scss


.
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}

params [: session] [: remember_me] If on, receive the value of '1', if off, receive the value of '0' '1' or '0' instead of 1 or 0

Add the following to the create action of the Sessions controller

if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end

The above can be rewritten as follows (ternary operator)

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

Completion of a permanent login mechanism up to this point

9.3 [Remember me] test

9.3.1 Test the Remember me box

Define a helper method that allows the user to log in within the test

Can be used for unit tests and integration tests Define log_in_as method in each of class ActiveSupport :: TestCase and class ActionDispatch :: IntegrationTest

Because the integration test cannot handle session directly Use post login_path in the log_in_as method for integration testing

test/test_helper.rb


ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  #Returns true if the test user is logged in
  def is_logged_in?
    !session[:user_id].nil?
  end

  #Log in as a test user
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

class ActionDispatch::IntegrationTest

  #Log in as a test user
  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

rails test(GREEN)

9.3.1 Exercise:

Let's have two problems

As a premise, the @ user defined by the setup method in the integration test is Does not include the remember_token attribute

Why you need attr_accessor and why you don't (Thanks to Rails): Rails Tutorial Memorandum-Chapter 9

/sample_app/app/controllers/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

/sample_app/test/integration/users_login_test.rb


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

9.3.2 Test [Remember me]

Testing the behavior of current_user

ʻAssert_equal , `

/sample_app/test/helpers/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

9.4 Finally

If you do the following, it will be temporarily inaccessible The maintenance page can be displayed

$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off

Recommended Posts

I changed the way Rails tutorials run: Rails Tutorial Notes-Chapter 9
I rewrote the Rails tutorial test with RSpec
11.1 AccountActivations Resources: Rails Tutorial Notes-Chapter 11
I tried the Docker tutorial!
I tried the VueJS tutorial!
Piped together grep ?: Rails Tutorial Notes--Chapter 8
I made a reply function for the Rails Tutorial extension (Part 1)
[Rails] I tried deleting the application
I made a reply function for the Rails Tutorial extension (Part 5):
Testing for Error Messages: Rails Tutorial Notes-Chapter 7
11.2 Send Account Activation Email: Rails Tutorial Notes--Chapter 11
rails tutorial
rails tutorial
rails tutorial
rails tutorial
rails tutorial
rails tutorial
rails tutorial
Where I got stuck in today's "rails tutorial" (2020/10/08)
[Rails] When I use form_with, the screen freezes! ??
[Rails] I tried to raise the Rails version from 5.0 to 5.2
I tried to organize the session in Rails
Where I got stuck in today's "rails tutorial" (2020/10/05)
The code I used to connect Rails 3 to PostgreSQL 10
Where I got stuck in today's "rails tutorial" (2020/10/06)
Where I got stuck in today's "rails tutorial" (2020/10/04)
I summarized the naming conventions for each Rails
I tried to set tomcat to run the Servlet.
Where I got stuck in today's "rails tutorial" (2020/10/07)