Setting up multi-device/browser session tracking for Devise

An approach for tracking Devise sessions for a single user across multiple browsers and devices

The other day, I wanted to set up a session tracking feature in my app to allow users to be able to know how many devices they were logged in from. The requirements for this feature would allow users to:

  • See a list of devices that currently has an active session

  • View the requesting IP address of these sessions

  • Terminate other sessions

Here’s how the final product looks like:

I haven’t yet added the geolocation functionality here (which is why you see Unknown under the Location column), but this can be added easily by calling a third-party API.

I won’t go into all the steps in creating the UI, but I’ll share and explain all the pertinent plumbing code needed to get this done below. So let’s get started…

Create a model to store session history

Although Devise comes with a feature to log the IP address of the currently logged in user, we need to be able to persist multiple sessions. In order to do this, we’ll create a model.

bin/rails g model LoginSession ip_address:inet properties:text status:integer session_id:string:index user:references

Within this model, we’ll define a few states — as described by using a status enum:

class LoginSession < ApplicationRecord

  enum status: { inactive: 0, locked_out: 50, active: 100 }

  store :properties, accessors: [ :browser_name, :browser_version, :os_name, :os_version, :location ]

end

You’ll see here that I’ve added a third state: locked_out. The purpose of this model is also keep track of suspicious IP addresses. As well, I’ve added a bunch of properties (using the text column) to store things like browser_name, etc. You may want to elect in using jsonb or just simple columns to make future querying and analyzing easier.

In this post, I won’t be talking about analyzing IP addresses and blocking them, but it’s important that as you’re building this functionality to think of ways on how to use this feature. For example:

  • If a user logs in from a new IP address, halt the authentication process and ask for email confirmation

  • IP addresses can be checked against a blacklist

  • Whitelist capabilities on which IPs can login to the user account

  • Notify users of when a login has occurred

The possibilities are many, and I would encourage you to think of ways in which you can secure your app by implementing session tracking.

Storing session history

The next step is to store the session history. Whenever we’re dealing with sessions, we’ll need a way to access and manipulate them. Rails, by default, uses the cookie session store. We’ll need to be able to destroy sessions that are not currently ours (eg. if the session is on a separate device). As a result, we’ll be switching to a database store.

Rails no longer ships with ActiveRecord as a session store option, but you can add it back in a gem:

gem 'activerecord-session_store'

Add the above to your Gemfile and run the usual ‘bundle install’ comand.

Next, modify your application.rb to use ActiveRecord as the session store:

config.session_store :active_record_store, key: '_myapp'

Once you’re done that, we’re ready to start storing session data.

In your application_controller.rb (ApplicationController) file, we’ll be using the after_sign_in_path_for method provided by Devise to run the following code to create a LoginSession instance whenever a user logs in:

def after_sign_in_path_for(resource_or_scope)
  if resource_or_scope.is_a?(User)
    resource_or_scope.login_sessions.create(ip_address: request.remote_ip)
    session[:login_session_id] = login_history.id
  end
end

(In case it’s not clear, I’ve added a has_many associations to User for the LoginSession model.)

What you see here is that we only store the IP address of the current user. Then immediately, we store the ID of the LoginHistory instance we created.

What we want to do is to store the session.id into our LoginSession model. So why can’t we do that here?

The reason is that Warden (the library that Devise relies on to handle authentication at the Rack middleware level) changes the session ID to prevent session fixing vulnerabilities. If we store the session ID at this step, it will become stale in the next request as Warden refreshes the session ID in the next request.

As a result, we store the LoginHistory instance’s ID, and we’ll synchronize things back on the next request.

Syncing Session ID

In order to sync up the session ID into our LoginSession instance, we have now stored our current LoginSession record’s ID into a session variable: session[:login_session_id].

So let’s add the following code into our application_controller.rb to bring this to full circle:

after_action :sync_login_history

def sync_login_history
  if user_signed_in?
    unless session[:login_session_id].nil?
      begin
        login_session = current_user.login_sessions.find(session[:login_history_id])
        login_session.session_id = session.id
        login_session.status = "active"
        # Other methods to store request.user_agent
        login_session.save
      rescue ActiveRecord::RecordNotFound => e

      end
    end
  end
end

Let’s explain what’s happening here.

First, we determine that the user is logged in and that the session[:login_session_id] has been set. Once that’s set, we’ll attempt to retrieve the login session. If it isn’t found, we rescue it with ActiveRecord::RecordNotFound. It’s ideal to put some additional error handling here (eg. alert you, the programmer) in case something has slipped through.

Next, we finally set the LoginHistory#session_id here to session.id, which will be the session ID that’s associated with the currently logged in user.

You’ll also notice in the above snippet of code that you can store user agent (ie. browser) information. I’ve left that out in my code sample for simplicity’s sake, but basically here’s where you populate the “properties” column that I had set up above. There are a number of Ruby gems that will help you parse this information as well.

What happens on sign out?

When a user signs out, we need a way to flag a LoginSession as “inactive”. We can’t use some after callback in the controller because, like before, Warden changes the session ID. As a result, there’s no way to refer back to the same LoginSession. Instead, we leverage some hooks that Warden provides.

Create a file in the /config/initializers directory and call it warden_callbacks.rb (the name isn’t important, by the way).

Paste the following code there:

Warden::Manager.before_logout do |user, auth, opts|
  session_id = auth.env['rack.session'].id
  if LoginSession.where(session_id: session_id).any?
    LoginSession.where(session_id: session_id).update_all(status: "inactive")
  end
end

We’ll be able to retrieve the current session ID right before Warden resets the session here. Once we have the session ID, we can find the LoginSession that we’re on and set it as inactive.

How do I destroy other sessions?

One of the most powerful uses in implementing session tracking is that it enables us to log off other sessions.

To find the session record, by using the session_id stored in our LoginSession model, we can execute this command:

login_session = user.login_sessions.find(params[:id)

session_store_record = ActiveRecord::SessionStore::Session.find_by(session_id: login_session.id)
session_store_record.destroy

The ActiveRecord::SessionStore::Session provided by the activerecord-session_store gem acts just like any other ActiveRecord model. Once you call destroy on it, the other session will be destroyed.

Conclusion

Tracking sessions is a powerful way to harden your Ruby on Rails application, and allows your users a greater level of control and the ability to audit their login activities.

Did I miss anything here that might be useful? Let me know by reaching out to me @geetfun on Twitter.

As always, thanks for subscribing to my Rails Dev notes Substack. Be sure to share this with your fellow developer colleagues and friends. I’d love to hear your feedback on what I’ve written, especially if you know of better ways of implementing what I described.

Thanks again for reading!

Simon Chiu
@geetfun

Addendum(s):

[May 7, 2020] When modifying Rack as I did above, if you’re also using Sidekiq Web, be sure to disable sessions like this in routes.rb. Otherwise Sidekiq Web won’t work.

Sidekiq::Web.set :sessions, false