Lesson n°6

Secure session

Signed session cookies

In the previous chapter, we stored the raw user_id in the session cookie to identify a given user. This is very insecure because the user_id is an incremental value, making it very easy to change the value of the cookie to impersonate another user.

To illustrate the security issue, let's completely reset the database:

bin/rails db:drop db:create db:migrate

We can then open a rails console and create two users:

User.create! email: "[email protected]", password: "password"
User.create! email: "[email protected]", password: "password"

This will create two users in the database. The first one has an id of 1 and the second one has an id of 2.

Now let's open a new private browser window to ensure that we don't already have a session cookie. Note that we can also open a standard browser window and manually delete the cookies.

Let's open the sign-in form and sign in with the first user. We should be redirected to the dashboard and see "Welcome [email protected]".

Sign in with the first user

Let's now imagine we are an attacker and want to impersonate the second user. We can open the browser developer tools and change the value of the user_id cookie to 2.

To do this with Google Chrome, we can open the developer tools with Cmd + Option + I and go to the Application tab. We can then find the localhost entry in the Cookies section and change the value of the user_id cookie to 2.

If we refresh the dashboard page, we should now see "Welcome [email protected]"! We were able to impersonate another user. This is a huge security issue!

Impersonate the second user

To fix this, we need to ensure the value of the cookie can't be tampered with. In the "User Model" lesson, we talked about signing as a mechanism to prevent tampering. We can use the same mechanism to sign the session cookie.

Let's make a simple change to our Authentication concern to use signed cookies:

# app/controllers/concerns/authentication.rb

module Authentication
  # All the previous code

  def restore_authentication
    Current.user = User.find_by(id: cookies.signed[:user_id])
  end

  # All the previous code

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

Let's now perform the same experiment again. Let's close the previous private browser window to clear all the cookies and open a new one or simply clear the cookies manually from the current browser window.

Let's open the sign-in form and sign in again with the first user. We should once again be redirected to the dashboard and see "Welcome [email protected]".

If we now try to change the value of the user_id cookie to 2, we should see that it's not as easy as it previously was. The value of the cookie is now signed and looks like the following:

eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iLCJleHAiOiIyMDQ0LTA3LTI5VDEwOjQ3OjM3LjUxM1oiLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--03ec036f73f17ae7e3f876b3f74541d8032f49d9

Urgh, says the attacker, that's going to be much harder to change to impersonate another user.

Let's have a closer look at this string and analyze it. As we can see, it's divided into two parts separated by two dashes --. The first part is the actual value of the cookie. The second part is the signature that is unique to this value.

If you remember well from the User Model lesson where we talked about signing, the first part of the string is public and anyone can read it simply by decoding the Base64 text:

payload, signature = "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iLCJleHAiOiIyMDQ0LTA3LTI5VDEwOjQ3OjM3LjUxM1oiLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--03ec036f73f17ae7e3f876b3f74541d8032f49d9".split("--")

value = JSON.parse(Base64.decode64(CGI.unescape(payload)))
# => {"_rails"=>{"message"=>"MQ==", "exp"=>"2044-07-29T10:47:37.513Z", "pur"=>"cookie.user_id"}}

We can further decode the message part of the payload, and we will get a user_id of 1:

Base64.decode64(value["_rails"]["message"])
# => "1"

However, as the signature part is unique to this value, even if we replaced the 1 with 2 in the payload, the signature would become invalid and the cookie would be rejected!

With signed cookies, our authentication system is already much more secure than it was before.

HTTP-only cookies

One of the most common vulnerabilities in web applications is Cross-Site Scripting (XSS). This vulnerability allows an attacker to inject malicious scripts into web pages viewed by other users. These scripts can steal sensitive information such as session cookies and send them to the attacker. That way, the attacker can impersonate the user and perform actions on their behalf.

By default, Rails protects you against XSS attacks, but in my experience, it's quite common to have subtle XSS vulnerabilities in web applications. To protect cookies against this kind of attack, we can mark them as being HTTP-only by using the http_only: true option. Browsers will prevent HTTP-only cookies from being accessed by JavaScript.

Before we make this change, let's first try to access the value of the session cookie with JavaScript in the browser's development tools.

In a new private browser window, let's sign in with the first user. We should be redirected to the dashboard page and see the message "Welcome [email protected]".

Let's now open the browser's development tools with Cmd + Option + I and go to the Console tab. We can then run the following JavaScript code to access the value of the user_id cookie:

document.cookie.split(";").find(cookie => cookie.includes("user_id"))

// =>  user_id=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iLCJleHAiOiIyMDQ0LTA3LTI5VDEwOjQ3OjM3LjUxM1oiLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--03ec036f73f17ae7e3f876b3f74541d8032f49d9

As we can see, we were able to access the value of the user_id cookie with JavaScript. This is a security issue as an attacker could inject malicious scripts into the page and steal the session cookie.

Let's now make our cookie HTTP-only so that it can't be accessed by JavaScript:

# app/controllers/concerns/authentication.rb

module Authentication
  # All the previous code

  def sign_in(user)
    cookies.signed.permanent[:user_id] = { value: user.id, httponly: true }
  end
end

Let's close the private browser window and open a new one or simply clear the cookies manually from the current browser window. If we sign in again with the first user, we should be redirected to the dashboard page and see the message "Welcome [email protected]".

If we now reopen the JavaScript console from the dev tools and try to access the value of the user_id cookie, we should see that it's undefined:

document.cookie.split(";").find(cookie => cookie.includes("user_id"))

// => undefined

This means that the cookie is now HTTP-only and can't be accessed by JavaScript.

Session model

Let's now imagine the following scenario: a user contacts the support team because of suspicious activity on their account. The user remembers logging in on a public computer (at the airport, for example) and forgot to log out. How can we log out the user from all sessions?

If we are using the user_id as the value we store in the cookies for authentication, it's going to be quite hard to log the user out as the user_id is always the same for all sessions. As the session cookie exists on the public browser for the next 20 years, we can't really do anything about it.

To solve this issue, we can create a new Session model where a user has many sessions and a session belongs to a user. We can then store the session_id in the cookies. In order to authenticate a user, we can simply find the session by its id and check the associated user.

Let's go ahead and implement this model. We can generate a new model with the following command:

bin/rails generate model Session user:references
bin/rails db:migrate

The generated Session model should look like this:

# app/models/session.rb

class Session < ApplicationRecord
  belongs_to :user
end

Let's update our User model to reflect the fact that a user can now have multiple sessions:

# app/models/user.rb

class User < ApplicationRecord
  has_many :sessions, dependent: :destroy

  # All the previous code
end

We can now simply update our Authentication concern to create a new session when a user signs in:

# app/controllers/concerns/authentication.rb

module Authentication
  def require_authentication
    restore_authentication || request_authentication
  end

  def request_authentication
    redirect_to new_session_path, notice: "You must be signed in to perform that action"
  end

  def restore_authentication
    if session = session_from_cookies
      Current.user = session.user
    end
  end

  def sign_in(user)
    session = user.sessions.create!
    cookies.signed.permanent[:session_id] = { value: session.id, httponly: true }
  end

  def session_from_cookies
    Session.find_by(id: cookies.signed[:session_id])
  end
end

Let's break down this code together. When signing in a user, we create a new session associated with this user and store the session's id in the cookies. This means that each time the user logs in with a new device, a new session is created and stored in the database.

On the pages where authentication is required (such as the dashboard page in our example), we will try to find a session from the session_id in the cookies. If a session is found, we set the Current.user to the user associated with the session. You can try it in the browser and it should work exactly as it did before.

Now back to our airport computer scenario: how can we log out the user from all sessions? Well, that's very simple now. We can simply delete all the sessions associated with the user:

user = User.find(id_of_the_user)
user.sessions.delete_all

On the public computer, the old session id won't exist anymore, so the session_from_cookies method will return nil. The user will be logged out from all sessions.

Note that some websites such as GitHub implement a feature to see all your active and inactive sessions and give you the ability to revoke them if needed. If you want to try with your GitHub account, you can visit the sessions index page at https://github.com/settings/sessions. This is a great feature to have in your application and can be implemented by simply listing all the sessions associated with the user and adding an action to delete them!

We have solved our public airport computer problem!

Conclusion

We did it! By successive iterations, we went from an insecure authentication mechanism to a secure one. We now have a secure way to authenticate users and we can easily log them out from all their sessions if needed.

In a real application, we might want to add additional information to the session such as the user agent, the IP address, the last activity date, etc. We could also allow users to revoke some of their sessions exactly like on GitHub.