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
-Add a function to store login information at the user's discretion and log in even if the browser is restarted.
↑ 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.
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?
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_accessor
Remember 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+\-.]+@[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"
>>
class << self
If 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.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
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. )
Yes.
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
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.
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.).
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.
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 user
Just 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.
↑ 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
Now that we have implemented Remember me, we will create tests as well.
`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')
Since the default value is set, it is not necessary originally, but the remember_me attribute is also entered for easy comparison.
##### Exercise
1. In the integration test of ↑, I only tested that cookies are not empty because the virtual attribute remember_token cannot be accessed.
```assigns```You can get the instance variable of the last accessed action by using the method.
In the above test example, we are accessing the create action of sessions_controller inside the ``` log_in_as``` method.
The value of the instance variable defined by the create action can be read out using a symbol.
In particular
Currently, the create action uses a local variable called user, so add @ to this and call it @user.
By changing it to an instance variable, the assigns method can be read.
After that, @user can be read by setting ```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
I have implemented login processing and session related helper methods in sessions_helper
current_user
Not 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.
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.
Recommended Posts