Jon Leighton

Revocable sessions with Devise

By default, session data in Rails is stored via a cookie in the user’s browser. It’s a nice, simple storage mechanism, but it means that the server has absolutely no “memory” of a given session. This can cause security problems for your application.

What if your user’s laptop gets stolen? If the thief gets hold of the user’s session cookie, then they can get into the user’s account. The user might reasonably think that changing their password will solve this, but it won’t: the server has a chronic case of amnesia, and has no idea when a given session cookie was created or who by. Your only way of locking the thief out would be to change the session secret, thereby invalidating all session cookies.

There are several ways to mitigate this problem:

I wanted to implement the latter solution for my company, Loco2, and I was pretty surprised to find very little said about it on the web. The closest thing I could find was this blog post, but it took a lot of faffing to figure out how to apply that technique to Devise, which is our chosen authentication solution.

Update regarding Devise: I’ve learned from José Valim that Devise does implement a mechanism to invalidate previous sessions when a password is changed. It does this by storing a salt in the session. When the password is changed, the salt also changes, and sessions with an invalid salt are rejected. This doesn’t solve the issue of “logged out” sessions being reused, but it’s nice to know that Devise deals with the password change issue out of the box.

So in the interest of reducing faff-time for some poor soul in the future, here is a rough sketch of our solution. Turning it into a nifty shrink wrapped gem is left as an exercise for any reader who is less perpetually tired of reading bug reports on Github than I am!

We’ll add a SessionActivation model to track which sessions are active. Here’s the migration:

class AddUserActiveSessions < ActiveRecord::Migration
  def change
    create_table :session_activations do |t|
      t.integer :user_id,   null: false
      t.string :session_id, null: false
      t.timestamps
    end

    add_index :session_activations, :user_id
    add_index :session_activations, :session_id, unique: true
  end
end

Here’s the class:

class SessionActivation < ActiveRecord::Base
  LIMIT = 20

  def self.active?(id)
    id && where(session_id: id).exists?
  end

  def self.activate(id)
    activation = create!(session_id: id)
    purge_old
    activation
  end

  def self.deactivate(id)
    return unless id
    where(session_id: id).delete_all
  end

  # For some reason using #delete_all causes the order/offset to be ignored
  def self.purge_old
    order("created_at desc").offset(LIMIT).destroy_all
  end

  def self.exclusive(id)
    where("session_id != ?", id).delete_all
  end
end

The purge_old method ensures that there’s a limit to the number of active sessions that a given user can have, to stop session activations piling up in the database. (We’ll only call SessionActivation.activate through the User#session_activations association, read on…)

Make some changes to the User model:

class User < ActiveRecord::Base
  # ...

  has_many :session_activations, dependent: :destroy

  def activate_session
    session_activations.activate(SecureRandom.hex).session_id
  end

  def exclusive_session(id)
    session_activations.exclusive(id)
  end

  def session_active?(id)
    session_activations.active? id
  end
end

Finally, we need to hook all this up to Devise. Well, actually we hook it up to Warden which underlies Devise. I tried for a while to implement this just using the controller layer of Rails, because I hate the fact that I have to basically define global callbacks in a config file in order to influence the behaviour of my application. But it didn’t work, so global callbacks it is. Sigh.

Stick this in your config/initializers/devise.rb:

Warden::Manager.after_set_user except: :fetch do |user, warden, opts|
  SessionActivation.deactivate warden.raw_session["auth_id"]
  warden.raw_session["auth_id"] = user.activate_session
end

Warden::Manager.after_fetch do |user, warden, opts|
  unless user.session_active?(warden.raw_session["auth_id"])
    warden.logout
    throw :warden, message: :unauthenticated
  end
end

Warden::Manager.before_logout do |user, warden, opts|
  SessionActivation.deactivate warden.raw_session["auth_id"]
end

Here’s what we’re doing:

  1. After authenticating, we’re removing any session activation that may already exist, and creating a new session activation. We generate our own random id (in User#activate_session) and store it in the auth_id key. There is already a session_id key, but the session gets renewed (and the session id changes) after authentication in order to avoid session fixation attacks. So it’s easier to just use our own id.
  2. After fetching a user from the session, we check that the session is marked as active for that user. If it’s not we log the user out.
  3. When logging out, we deactivate the current session. This ensures that the session cookie can’t be reused afterwards.

That’s it! Now you’re free to invalidate sessions til your heart’s content. For example, when a user changes their password in our application, we call User#exclusive_session to ensure that all other sessions except the current one are invalidated. (If you’re using a remember token, make sure you invalidate that too.)

10 October 2013

Comments