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.
Here's the end result:
Steps:
last_request_at
(datetime) and logged_in
(boolean) to your user modelauthenticate_user!
in application_controller.rb
and set last_request_at
from the user's session informationlogged_in
to be true/falseGetting 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.
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.