[RUBY] Let's get rid of Devise's sickness and lead a comfortable Rails life!

This is a reprint of the article posted on Zenn. https://zenn.dev/kitabatake/articles/start-to-like-the-devise

This article aims to eliminate the smoky feeling that tends to be felt when using Devise, which is famous as a gem of Rails' user authentication function. I chose this title because I felt uncomfortable, but I hope you read it simply for the purpose of "deepening your understanding of Devise."

Devise is very convenient, isn't it? All you have to do is install, install the config file, configure your model, and write devise_for in routes.rb. With just this ...

--Creating a new user registration screen --Creating a login screen --Creating a screen when you forget your password --Grant various methods for user authentication such as authenticate_user! and user_signin?

It does various things related to the user authentication function such as. It's amazing.

At the cost of "doing a lot with the minimum settings", I think there is a point that "it is difficult to grasp the whole picture of what Devise does". It's unpleasant to be in a state where you can't grasp the whole picture even though you play an important part in the project called "user authentication". Of course, if you look it up properly, you'll understand, but it takes patience and time to look it up properly, so I think there are many people who have left it uncomfortable.

Devise also offers a great deal of flexibility for deployment in a variety of projects. A complicated mechanism is required internally to enable flexible settings. Therefore, even a small adjustment work becomes difficult when it is necessary to understand the internal processing. I think that the fact that you don't feel the touch and feel a black box is also a point that makes you feel uncomfortable.

We will try to eliminate the causes of these two ** "difficult to grasp the whole picture" ** and ** "feeling like a black box" ** by the following approach.

  1. Clarify what "user authentication for Devise" is as a prerequisite knowledge
  2. Get the full picture of Devise by knowing exactly what Devise will do
  3. Eliminate the feeling of a black box by grasping the inside of Devise

Let's look at them in order.

What does "authenticate users" mean to Devise?

First of all, as a prerequisite, I would like to clarify what it means to "authenticate the user" for Devise.

In general WEB services, user authentication can be expressed as "confirming who the accessing user is". For Devise, the "who" is the "model record." Therefore, for Devise, "authenticating a user" can be described as ** "checking which record of which model the accessing user is" **.

"Which model" is determined by the URL of the authentication screen. By default, "which record" is matched using email and password. (There are other ways to collate, but I'll omit it here because I don't need a detailed explanation here.)

Another flexible part of Devise is that you can set multiple models to authenticate. If you want to use the User model and Customer model for authentication, write as follows in routes.rb.

devise_for :users
devise_for :customers

In this case, / users/sign_in is the URL of the User model authentication screen, and/customers/sign_in is the URL of the Customer model authentication screen.

In summary, --For Devise, "authenticating a user" means ** "checking which record the user is on which model" ** --Multiple models to be authenticated can be set, and which model to authenticate the user is determined by the URL.

I thought it was important to understand these two points in order to understand Devise, so I explained it at the beginning.

Get the full picture of Devise's work

Devise does ** a lot of work ** to implement the user authentication feature. By knowing this ** "various" ** part concretely, we aim to eliminate "difficulty in grasping the whole picture" which is one of the causes of moyamoya.

The work that Devise does can be broadly divided into the following three.

  1. Granting basic methods of user authentication
  2. Creation of helper method for each authentication model
  3. Work required for each module

** 1. Granting basic methods of user authentication ** gives ApplicationController basic methods such as sign_in method required for user authentication function when Devise is installed. Will give you.

** 2. Creating a helper method for each authentication model ** is a method that contains the name of the authentication model, such as the current_user method, when devise_for in the routes.rb file is called. Will create.

** 3. The work required for each module ** is to realize the function of each module according to the module settings of the devise_for method in the routes.rb file and the devise method in the model. It will do the work you need.

Let's take a closer look at each.

Granting basic methods of user authentication

Just install the Devise gem and it will give the Application Controller basic methods for user authentication. I can't show you all because there are so many, but I'll pick up some so that you can imagine what kind of things will be given.

sign_in

Associate the user with the model instance (login)

sign_in(:user, @user)

sign_out

Break the link between the user and the model instance (log out)

sign_out(:user)

after_sign_in_path_for

Get the path to redirect after login

after_sign_in_path_for(:user)

If you want to know more details, please see the following directory. https://github.com/heartcombo/devise/tree/dfbed22cee617992e5f846fdef58b724b6b32ff9/lib/devise/controllers All Modules in this directory will be included in ApplicationController. By being included in ApplicationController, it can be called from all Controllers.

Creation of helper method for each authentication model

It creates familiar methods for those who have used Devise, such as curret_user andauthenticate_user!. These methods are created for each model for authentication, so if you are using the User and Customer models for authentication, for example, current_user and current_customer will be created respectively.

The location where it is created is created in a Module called Devise :: Controllers :: Helpers, which is included in ApplicationController. Therefore, it can be called from all Controllers.

These methods are created in devise_for in routes.rb. Since the methods are created literally dynamically, many IDEs are unable to jump to these methods with "Jump to Definition". You can see the code that makes the method from here, so if you are interested, please take a look.

There are four methods that will be created. (Assuming that User is set as the model for authentication)

Method name Overview
authenticate_user! Check if it is authenticated, and if it is not authenticated, take action when authentication fails (example):Redirect to top page)
user_signed_in? Whether it is authenticated
current_user Get an instance of an authenticated model
user_session Access the session information of the authentication model

Also, methods other than authenticate_user! can be called from View. (Internally calling helper_method of Controller)

Work required for each module

A module is a collection of "features" related to user authentication. You can set "Which module to use" for each authentication model.

Settings can be made with the model's devise method. By default, it is set using 5 modules as follows.

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
end

The work required for each module varies depending on the "function of the module", but it can be roughly divided into the "defining Routes" part and the "extending the model" part. In order to understand the specific work content, I will explain two modules.

** Database Authenticatable ** Module work

** Database Authenticatable ** is a module of "Ability to authenticate users using password stored in DB". The following Routes required to realize this function are defined.

--Login screen ** [GET]/users/sign_in ** to devise/session # new --Login process ** [POST]/users/sign_in ** to devise/session # create --Logout processing ** [DELETE]/users/sign_out ** to devise/session # destroy

Implementations of actions corresponding to Routes are provided in the app directory in the (Controller and View) gem. If you want to know how the implementation of actions under the app directory in gem can be set in Routes, please see Introduction to Rails Engine.

The model is extended as follows:

--Make encrypted_password and email required columns for collating users --Add various methods required for password authentication -** password = (new_password) : Set password - valid_password? (password) **: Check if the password is valid

Many other methods will be added, so if you want to know more, please see rubydoc documentation.

** Validatable ** Module work

** Validatable ** is a module that validates email and password such as "whether or not input" and "check format". There are no Routes and model extensions are minimal. The main task is to set the Validation for email and password using the validates_presence_of method and the validates_format_of for the model's Validation.


As you can see, the work required depends on the "function of the module", but other modules generally do this. For other modules, there is a link to rubydoc for each module at the beginning of the Devise README, which gives details.

As mentioned above, I tried to solve one of the causes of moyamoya, "difficult to grasp the whole picture", by grasping the work that Devise does. Since there are 10 modules, it is difficult to grasp it perfectly, but if you recognize it as follows, it will be quite refreshing.

--Separate the base part and the part for each module --The job of the basic part is mainly to give ApplicationController the basic methods required for user authentication. --The work for each module is mainly "Defining Routes" and "Extending the model" --See rubydoc if you want to know more about model extensions

Now that I have a complete picture of Devise's work, I would like to try to "eliminate the feeling of a black box" by grasping the inside of the authentication process.

Understand the inside of the processing around authentication

Let's take a look inside the process around Devise authentication. Specifically, the processing around authentication is "login processing", "logout processing", and "acquisition processing of logged-in user information".

"Login process" is a little complicated, so let's put it off and first look at "logout process" and "login user information acquisition process".

Logout process

Logout processing is performed by the sign_out method defined in Devise :: Controller :: SignInOut. Devise :: Controller :: SignInOut is included in ApplicationController, so it can be called from any Controller.

Image of using sign_out method:

sign_out(:user)

Let's paste the code for the sign_out method. You don't have to read it carefully, but it's okay to take a quick look, so I think it's better to get a feel for it.

Partial excerpt from lib/devise/controller/sign_in_out.rb:

# Sign out a given user or scope. This helper is useful for signing out a user
# after deleting accounts. Returns true if there was a logout and false if there
# is no user logged in on the referred scope
#
# Examples:
#
#   sign_out :user     # sign_out(scope)
#   sign_out @user     # sign_out(resource)
#
def sign_out(resource_or_scope = nil)
    return sign_out_all_scopes unless resource_or_scope
    scope = Devise::Mapping.find_scope!(resource_or_scope)
    user = warden.user(scope: scope, run_callbacks: false) # If there is no user
    
    warden.logout(scope)
    warden.clear_strategies_cache!(scope: scope)
    instance_variable_set(:"@current_#{scope}", nil)
    
    !!user
end

Although it is a 7-line method, the actual logout process is the part of warden.logout (scope) in the middle. The scope is a symbolic representation of the authentication model, for example, in the case of the User model, : user is entered. Since Devise can use multiple models for authentication, it is necessary to identify which model the process is for in each process. So, I found that the logout process was delegated to something called warden.

Warden is a Rack Middleware that enables you to use the user authentication function. https://github.com/wardencommunity/warden

The first sentence of the Devise README also says "Devise is based on Warden." Partial excerpt from Devise's README:

Devise is a flexible authentication solution for Rails based on Warden

First of all, in a nutshell, Rack Middleware is like "a job before Rails processes a request." In the case of Warden, the "one job" is "to enable the user authentication function".

If you want a good understanding of Rack Middleware, this article should be easy to understand. https://qiita.com/nishio-dens/items/e293f15856d849d3862b

So, in order to know the inside of the logout process, it seems necessary to take a look at Warden's logout method.

Warden logout process

Warden's logout process is implemented in the logout method of the Warden :: Proxy class. Since it is a logout process, the main process is "deletion of authenticated user information".

Partial excerpt from lib/warden/proxy.rb:

def logout(*scopes)
  # ...
    scopes.each do |scope|
        user = @users.delete(scope)
        
        # ...
    
        session_serializer.delete(scope, user)
    end
    # ...
end

There is also a scope here. As explained earlier, it is a symbolization of the authentication model, which is : user in the case of the User model. Since it has a variable length argument of * scopes, it is possible to specify something likelogout (: user)orlogout (: user,: customer).

Devise's ability to use multiple models for authentication means that it still leverages Warden's concept of scope. https://github.com/wardencommunity/warden/wiki/Scopes

Looking at the code of the logout method, we are deleting two data for each scope.

⑴. Delete from users instance variable

@users.delete(scope)

The users instance variable is a variable that holds "authenticated user information" for each scope. Devise's current_user anduser_signin?Methods refer to this value and should be removed.

⑵. Deleted from Session

session_serializer.delete(scope, user)

The word Session has come up, so I'd like to briefly explain it. If you already know it, you can skip the following two paragraphs.

Session is a mechanism to retain data across access. Considering the user authentication function, once you log in, you need to take over the "logged-in state" for subsequent access, so this mechanism is necessary. The place where data is stored is generally called Session Storage, and Redis, Database, cookies, etc. can be used as Session Storage.

Session Storage holds Session data for all users who visit your site. Therefore, it is necessary to determine the Session data of the accessing user from the Session Storage, and for that purpose, the Session ID is used. The Session ID is a unique string that is generated by the server on first access. The generated Session ID is sent to the browser through a cookie, and the browser also sends it to the server. This Session ID allows the server to identify users across accesses.

So far, I've briefly explained Session. There are various easy-to-understand articles on the WEB about Sessions and cookies, so if you want to know more details, you should search.

Session is also implemented as Rack Middleware, which allows you to manipulate Session data before Warden. Specifically, based on the Session ID of the cookie, it seems that the Session data of the target user is specified from Session Storage and prepared so that it can be read and written.

You can read and write Session in the following ways.

env["rack.session"]["foo"] = "abc" # key:Against foo"abc"Write
env["rack.session"]["foo"] # key:Read the value of foo
env["rack.session"].delete("foo") # key:Delete foo

Rack Middleware communicates through env, so it looks like this. By the way, using Warden from Devise is also in the form of env ["warden "] .logout (: user).

I have derailed a little. It was said that the following code of Warden's logout method is removing "authenticated user information" from the session.

session_serializer.delete(scope, user)

The Key of "authenticated user information" of Session data is in the format warden.user. {Scope} .key. Therefore, the above code will execute the following code internally when : user is specified as scope.

env["rack.session"].delete("warden.user.user.key")

Since there are many characters and it is a little complicated, I tried to show the overall flow of the logout process in a diagram.

ログアウト処理のイメージ図

So far, I have looked inside the logout process. The structure is a little complicated, but I think that the feeling of a black box has diminished by understanding the characters and how to hold the data in the session.

Acquisition process of logged-in user information

Next, let's take a look inside the "login user information acquisition process". In the explanation of the logout process, I mentioned Warden and Session, so this is a quick one.

"Getting logged-in user information" is current_user oruser_sign_in?In Devise's method. These methods were dynamically generated for each model for authentication. Let's take a look at the code of the part that generates the method.

Partial excerpt from lib/devise/controller/helper.rb:

def #{mapping}_signed_in?
  !!current_#{mapping}
end

def current_#{mapping}
  @current_#{mapping} ||= warden.authenticate(scope: :#{mapping})
end

The # {mapping} part is the part that changes dynamically for each model for authentication, and in the case of the User model, user is entered.

Code of method generated for User model:

def user_signed_in?
  !!current_user
end

def current_user
  @current_user ||= warden.authenticate(scope: :user)
end

The user_sign_in? method just converts the result of current_user to Bool, isn't it? The current_user method is also just one line, calling Warden's authenticate method and storing the result in the current_user instance variable. By storing it in an instance variable, the second and subsequent accesses to current_user will return the instance variable directly, so the call to warden.authenticate will be omitted.

So, like the logout process, the main process is done by Warden. Let's take a look at the contents of Warden's authenticate method.

lib/warden/proxy.rb

The code is a bit complicated, so I won't list it here, but there are two main things I'm doing:

  1. If Session has authenticated user information, return it
  2. Execute user authentication process based on Strategy

Strategy of ⑵ will be explained in the next login process, so I will not touch it here.

⑴ "Obtaining authenticated user information from Session", but the basic part about Session data was explained in "Logout processing", so it will be omitted. Here, I would like to touch on "the process of converting user information to Session data" and "the process of restoring session data to user information".

For Devise, "user information" is a "model instance." The data held in Session is "model ID".

Therefore, when storing user information in Session, "convert model instance to ID" is required, and when retrieving user information from Session, "convert ID to model instance" is required. Warden has an option to set each process, and Devise does this.

--Process settings for storing user information in Session: serialize_into_session --Settings for reading user information from Session: serialize_from_session

The location is set below, but it may be difficult to understand because it is quite abstract. lib/devise.rb

I don't think it's necessary to understand the details of the processing, but I think it's good to understand the relationship between Warden and Devise. Warden deals only with abstract "user information" without knowing the existence of models and Rails, and allows you to set specific processing as an option. Devise makes it possible to utilize the "user authentication function" provided by Warden from Rails. Devise could be described as ** acting as a bridge between Rails and Warden **.

Login process

Finally, let's take a look at the login process. There are two ways to authenticate a user as a login process.

--Pass user information directly to the sign_in method --Execute Strategy via Warden's authenticate method

I will explain each of them.

Pass user information directly to the sing_in method

This method was introduced as a method for authentication given to ApplicationController in "Granting basic methods of user authentication" in "Getting the whole picture of Devise" mentioned above. Therefore, it can be called from all Controllers as follows.

sign_in(user)

In the callback action when implementing social login with Omniauth, I think that authentication processing is often performed using this method.

Devise sign_in method

Again, this method calls Warden's set_user method, delegating the main processing to Warden.

Warden set_user method

Warden's set_user method does the following two things:

--Store user information in the users variable --Store user information in Session

Of course, you're doing the opposite of the logout process. Since we have already explained the users variable and Session data, we will omit the explanation here and move on to the explanation of the second login processing method.

Execute Strategy via Warden's authenticate method

The ** Database Authenticatable ** module login process action "devise/session # create" uses this method.

Partial excerpt from app/controllers/devise/session_controller.rb

# POST /resource/sign_in
def create
    self.resource = warden.authenticate!(auth_options)
    
    # ...

    respond_with resource, location: after_sign_in_path_for(resource)
end

Warden's authenticate! method attempts to authenticate the user by returning the user information if it has already been authenticated and executing Strategy if it has not been authenticated.

Strategy is a mechanism to authenticate users based on request data. https://github.com/wardencommunity/warden/wiki/Strategies

Strategy needs to implement two methods: --valid?: Determine whether to execute Strategy based on the request data --authenticate!: Authenticate user based on request data

If you set the class that defines these methods in Strategies of Warden in advance, it will be called automatically when the authenticate! Method is called.

Devise uses this mechanism to authenticate users in the ** Database Authenticatable ** and ** Rebemerable ** modules.

For example, the ** Database Authenticatable ** module has the following implementation.


valid? Method: --Does the target authentication model allow password authentication? --Does the target action allow password authentication? --Is the authentication parameter set in the request data (email and password by default)?

authenticate! method: --Search for the target user from email and password


By utilizing the Strategy mechanism, the user authentication process can be separated from the implementation of the login process action. The implementation of the "devise/session # create" action mentioned above did not have "check parameter of request" or "find record from model", it just called warden.authenticate!. ..

On the other hand, the overall structure has become complicated, so I thought it would be difficult to generally evaluate Strategy as a "good mechanism." I wanted to clarify the "reason for using the mechanism called Strategy" and tighten it neatly, but it is difficult to think about design in this way.

I think that just knowing that "I am using a mechanism called Strategy" will soften the feeling of black box, so I wondered if this level of digging is good for the purpose of the article. It was.

At the end

I set the causes of the unpleasant feeling when using Devise as ** "difficult to grasp the whole picture" **, ** "has a feeling of a black box" **, and tried to eliminate each of them.

I hope that you have read this article and have alleviated your sickness.

By the way, I hear a lot of negative opinions about Devise. I also vaguely had that feeling.

If you delve into the negative feelings,

――By moyamoya like this time --Design and thought (eg, it is not a good design to put various things in the model)

I think there is.

I think that the cause of moyamoya is mainly due to lack of study, so it is practically difficult to dig into it every time, but I wanted to be humble as I am using it as OSS.

Regarding design, I think there are various opinions related to the problem domain and development system of the project we are working on. As we have seen this time, various parts can be used as Rack Middleware, so I think that it is one realistic method to create the authentication function by yourself according to the project.

If you're thinking about design, you'll find the article "Perfect Rails author's commentary on modern user authentication model configuration for devise" (https://joker1007.hatenablog.com/entry/2020/08/17/141621), which was talked about this year (2020).

Thank you for reading to the end! I would be happy if I could help you to lead a comfortable Rails life by eliminating the smoky feeling!

Recommended Posts

Let's get rid of Devise's sickness and lead a comfortable Rails life!
[Rails] How to get rid of flash messages in a certain amount of time
[Rails] Volume that displays favorites and a list of favorites
Let's get an internal representation of a floating point number
[Rails] How to get the URL of the transition source and redirect
Java, JS (jQuery) and Ajax. Get a specific value of JSON.
Creating a mixed conditional expression of Rails if statement and unless