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:

  • Store a created_at timestamp in the session and a password_updated_at timestamp on the user’s record in the database. Compare the two and disallow sessions which were created too long ago. This solves the password-change problem, but it doesn’t stop old sessions being reused - for example if the user clicks “log out” they might think their session was destroyed, but actually the session cookie was just deleted from their browser. If an attacker got hold of that cookie before they logged out (perhaps they were on a public computer which was recording cookies?), then the attacker can easily log back in and reuse the same session.
  • Switch to a server-side session store. If you store sessions on the server, you are at liberty to destroy them at any point.
  • Store a timestamp in your session so that you can timeout a session after a certain period of inactivity. If you use Devise, you can achieve this easily with the timeoutable module. This presents a slight conundrum: if you set the timeout too low, then it’ll be pretty annoying for your users. But the longer the timeout, the less useful it is for security (if the attacker gets in before hitting the timeout, they can just keep refreshing to keep their session active).
  • Track active session ids on the server side (whilst still storing the actual session data in cookies). This means you can invalidate sessions whenever you want.

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.)

Comments

I'd love to hear from you here instead of on corporate social media platforms! You can also contact me privately.

jed's avatar

jed

What are `exclusive` and `exclusive_session` methods used for? I see what they do, but am unsure how the would be helpful.

Jon's avatar

Jon

See the last paragraph and let me know if you still don't get it.

jejacks0n's avatar

jejacks0n

Thanks! Is there a nice way to test SessionActivation in isolation, or do you always test this pattern from the association? I can see that user_id is being set if it's being called from the association, but not if it's being called at a class level. Do you know what would need to be stubbed in the case of testing it in isolation so user_id can be set manually?

Kieran P's avatar

Kieran P

Here is a modified version which provides an upgrade path (all users aren't logged out when the code is deployed), and also updates an accessed_at timestamp on the users record. It also has a little less code in the user model. https://gist.github.com/Kie...

Christian Hansen's avatar

Christian Hansen

They way I read def "self.exclusive(id)" it is deleting all ActiveSessions and not just ActiveSessions of a particular user. Or are the ActiveSessions already scoped to a particular user when called by "def exclusive_session(id)" from an instance of the User model?
Thanks!

Peter K's avatar

Peter K

This really helped me. My goal was a bit simpler, I just wanted to make sure when a user logs out, they invalidate all sessions on all browsers. I posted my solution here: http://stackoverflow.com/qu...

Add your comment