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 apassword_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:
- 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 theauth_id
key. There is already asession_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. - 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.)
Comments
I'd love to hear from you here instead of on corporate social media platforms! You can also contact me privately.
jed
What are `exclusive` and `exclusive_session` methods used for? I see what they do, but am unsure how the would be helpful.
Jon
See the last paragraph and let me know if you still don't get it.
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
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
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
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