Tracking Currently Logged In Users with Devise

Devise needs a couple tweaks to track logged in users. Some of the solutions offered on the web are overkill. I'll demonstrate a simple solution here.

Devise-logo

TL;DR

Here's the end result:

alt tag

Steps:

  • Save last_request_at (datetime) and logged_in (boolean) to your user model
  • Reopen authenticate_user! in application_controller.rb and set last_request_at from the user's session information
  • Redefine devise's sessions_controller and on create / destroy (sign_in / sign_out) update logged_in to be true/false
  • Use rail's content_tag to generate a "time" html tag and target it with moment.js / jquery to display when the user was logged in last


Full Feature

A Quick Overview of Devise / Warden

Getting familiar with Warden might make this post make a little more sense so I'll show a few little tricks to get you jump started on understanding why what we're about to do works.

First, warden is a middleware that sits "in front" of your application. If you run rake middleware, you should see use Warden::Manager just before the routes of your application. If you've ever dabbled with rack middleware, the code for Warden::Manager should look familiar.

Second, Warden uses the notion of "strategies" for authentication. When you add a devise module like Rememerable, you're really adding the module AND telling warden what devise strategies to use.

Third, Warden supplies a set of hooks to use to run code before / after our authentication strategies run. Devise takes advantage of these hooks for almost all of its modules (see here) .

Lastly, Warden::Manager middleware sets some additional data on a request from the client before it hits our main application that we can inspect in tests, etc. This is helpful to visualize what data we have at our disposal, but also to understand what the heck Devise is doing.

Ok, so let's inspect a request with a simulated user login via a rspec test. First, let's add devise test helpers to our spec suite.

# spec/support/devise.rb

require 'devise'

module ControllerMacros
  def login(user)
    @request.env["devise.mapping"] = Devise.mappings[:user]
    sign_in(user)
  end
end

RSpec.configure do |config|
  config.include Devise::TestHelpers, :type => :controller
  # extend for before(:each) block
  config.extend ControllerMacros, :type => :controller
  # include to be within the 'it' block
  config.include ControllerMacros, :type => :controller
end



Now, since we're eventually going to reopen devise's authenticate_user!, which belongs in our application_controller.rb, let's write a test for our application controller. For now, I'm just going draw attention to a few objects that Warden / Devise strategies set on the request object.

# spec/controller/application_controller_spec.rb
##
# Note: make sure you have `before_action :authenticate_user!` in application_controller.rb

require 'rails_helper'

describe ApplicationController do
  let(:admin) { FactoryGirl.create(:admin) }

  controller(ApplicationController) do
    def index
      render nothing: true
    end
  end

  describe '#authenticate_user!' do
    context 'valid user session' do
      it 'sets the last_request_at' do
        get :index

        # binding.pry
        # request.env["warden"] => #< Warden::Proxy:702 >
        # request.env["devise.mapping"] => #<Devise::Mapping:0x0 @class_name: "User", @controllers={:sessions=>"user/sessions"} ...>
        # request.env["rack.session"] => {"warden.user.user.key"=>[[1], "$2a$04$ZLmIywobc4MQhvzEWNE4me"], "warden.user.user.session"=>{"last_request_at"=>1458064478}} 
      end
    end
  end
end



There should be a few takeaways from inspecting the warden / session objects above. First, is the Devise::Mapping object that, true to its name, maps our application's user model and routes for devise - in effect the configuration. Second, is the hashed key, and ID of the user in the "rack.session" variable. But the main takeaway, assuming you're using the timeoutable Devise module, is the last_request_at stored in the rack session. This timestamp is used to determine if the user's session has timed out. You can see this timeout check in devise's hook.

Our Changes to Track Logged In Users

Let's start with our migration:

class AddLastRequestAtToUsers < ActiveRecord::Migration
  def change
    add_column :users, :last_request_at, :datetime
    add_column :users, :logged_in, :boolean, default: false
  end
end



Next, let's add some code on login / logout to set the logged_in attribute of our users.

# routes.rb
devise_for :users, controllers: {sessions: 'user/sessions'}

# app/controllers/user/session_controller.rb

class User::SessionsController < ::Devise::SessionsController
  def create
    super
    current_user.update_attribute(:logged_in, true) # avoid validations
  end

  def destroy
    current_user.update_attribute(:logged_in, false) # avoid validations
    super
  end
end



You should notice the Devise::Mapping from the previous section points to the route 'user/sessions' which is no coincidence. Our modified devise sessions controller will now set users as logged_in: true upon sign in and mark them as logged_in: false when they signout, great! But now, you're likely asking, "Setting them as logged in doesn't consider a timedout session". And you'd be exactly right. So let's remedy that by tracking last_request_at and comparing that to the timeout_in method the Devise::Models::Timeoutable gives us.

# application_controller.rb
before_action :authenticate_user!

  def authenticate_user!(opts = {})
    super(opts)

    if current_user
      last_request_at = user_session["last_request_at"]

      if last_request_at.is_a? Integer
        last_request_at = Time.at(last_request_at).utc
      elsif last_request_at.is_a? String
        last_request_at = Time.parse(last_request_at)
      end
      # don't run the validations
      current_user.update_attribute(:last_request_at, last_request_at) # avoid validations
    end
  end



Great, now on each request, we update the current user's last_request_at field. This gives us all the data necessary to to track users who are logged_in within the timeout period. This now allows us to create a scope on the User model to filter all logged in users as well as a nice instance method.

# user.rb

class << self
  def currently_logged_in(logged_in = true)
    where(logged_in: logged_in).where("last_request_at > ?", Time.zone.now - timeout_in.seconds)
  end
end

def currently_logged_in?
  logged_in? && last_request_at.present? && !timedout?(last_request_at)
end



Simple right?

For extra credit, let's add a nice view to track the last time a user logged in.

First, add moment.js to your application.js. Next, wherever you're rendering, add some controller, etc. logic to determine if the user is logged in. Since, I'm using active admin in my app, I'll take advantage of their status_tag method.

# app/admin/user
index do
    column "Last Log In" do |user|
      if user.currently_logged_in?
        status_tag("Logged In", :ok)
      elsif user.last_request_at.nil?
        status_tag("Never", :error)
      else
        time = user.last_request_at
        content_tag(:time, time.to_s, datetime: time.getutc.iso8601)
      end
    end
end



Lastly, let's target each time html tag with our moment.js library (I'm using lodash because... duh).

(function() {
  'use strict';

  var setTimeAgo = function() {
    _.each($("time"), function(ele) {
      var dateString = moment($(ele).attr("datetime")).fromNow()
      $(ele).text(dateString)
    })
  }

  $(document).ready(function() {
    setTimeAgo()
    setInterval(setTimeAgo, 5000);
  });
}());



And we're done! Well, I'll let you figure out the CSS and unit / integration testing.