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_attimestamp in the session and a
password_updated_attimestamp 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
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
User#session_activations association, read on…)
Make some changes to the
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
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:
- 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_idkey. There is already a
session_idkey, 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.
- 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.
- 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.)