Recently, I have had to implement a feature on a Ruby on Rails application where I had to ensure the user session was expired after a period of inactivity. The application was using the Devise gem as the solution for user authentication.
Why do we need to expire user sessions?
According to the Security Rails Application Ruby on Rails guide:
Sessions that never expire extend the time-frame for attacks such as cross-site request forgery (CSRF), session hijacking, and session fixation.
So even though long-lived sessions can improve user experience, it’s also a potential security vulnerability as it opens a user up to having his / her session physically hijacked, for instance, when the user forgets to sign out using a computer in a public library.
OWASP recommends short idle session time outs (2–5 minutes) for applications that handle high-risk data, like financial information. It considers that longer idle time outs (15–30 minutes) are acceptable for low-risk applications.
Both the idle and absolute timeout values are highly dependent on how critical the web application and its data are. Common idle timeouts ranges are 2–5 minutes for high-value applications and 15–30 minutes for low risk applications.
Devise’s Timeoutable module
The Devise gem actually has a module called Timeoutable built-in that allows one to expire a user session that has not been active for a specified period of time.
To use the Timeoutable module with a User model, you simply need to include timeoutable into the model:
class User < ActiveRecord::Base
devise :timeoutable
endYou then need to specify how long the inactivity period should be for in config/initializers/devise.rb:
=begin
==> Configuration for :timeoutable
The time you want to timeout the user session without activity.
After this time the user will be asked for credentials again.
Default is 30 minutes.
=end
config.timeout_in = 30.minutesAnd then we’re done! Or are we?
Problem with this approach
Because the Devise Timeoutable logic runs on the server side, and not client side, the user would not get logged out until they make a request to the server (i.e: perform an action that calls a Rails controller).
The Timeoutable module works by checking the last web request time of the user, and using that to determine if the user session should be invalidated.
So the user will still be able to see the application UI until the next web request, even if the user session is supposed to be expired, and the user is to be logged out of the application.
This can be a problem if the application contains user sensitive information that one does not want to display to unauthorised users. Or if the application has a lot of the logic done client side, and one cannot always ensure that the business logic done on the application will be handled by the server.
Solution
To solve the aforementioned problem, we would need to implement a way for the client to poll the server and figure out if the client should expire the current user session and redirect the user to the login page.
We start by creating a couple of routes that the client can poll:
devise_scope :user do
get "/check_session_timeout" => "session_timeout#check_session_timeout"
get "/session_timeout" => "session_timeout#render_timeout"
endThe check_session_timeout route will return the remaining valid time for the user session in seconds. The session_timeout route will invalidate the current user session and direct the user to the login page.
We then create a SessionTimeoutController
class SessionTimeoutController < Devise::SessionsController
prepend_before_action :skip_timeout, only: [:check_session_timeout, :render_timeout]
def check_session_timeout
response.headers["Etag"] = "" # clear etags to prevent caching
render plain: ttl_to_timeout, status: :ok
end
def render_timeout
if current_user.present? && user_signed_in?
reset_session
sign_out(current_user)
end
flash[:alert] = t("devise.failure.timeout", default: "Your session has timed out.")
redirect_to login_path
end
private
def ttl_to_timeout
return 0 if user_session.blank?
Devise.timeout_in - (Time.now.utc - last_request_time).to_i
end
def last_request_time
user_session["last_request_at"].presence || 0
end
def skip_timeout
request.env["devise.skip_trackable"] = true
end
endThe check_session_timeout and render_timeout functions will provide the necessary functionality for the aforementioned routes.
Note the skip_timeout private function; this essentially sets a web request to NOT extend the current user session length. We do not want these two routes to extend the current user session in Devise. If not, each time the client polls these two routes, we are effectively extending the current user’s session.
Finally, we will need some client side JavaScript code to do the polling:
const sessionTimeoutPollFrequency = 5;
function pollForSessionTimeout() {
let request = new XMLHttpRequest();
request.onload = function (event) {
var status = event.target.status;
var response = event.target.response;
// if the remaining valid time for the current user session is less than or equals to 0 seconds.
if (status === 200 && (response <= 0)) {
window.location.href = '/session_timeout';
}
};
request.open('GET', '/check_session_timeout', true);
request.responseType = 'json';
request.send();
setTimeout(pollForSessionTimeout, (sessionTimeoutPollFrequency * 1000));
}
window.setTimeout(pollForSessionTimeout, (sessionTimeoutPollFrequency * 1000));
The above JavaScript snippet will poll the server every 5 seconds, and check the remaining valid time for the current user session. When the user session is expired, it will then redirect the client to the session_timeout route.
Optimisations
We can optimise the above client side code even further. We can add some client side logic to only start polling when we have detected that there is no keyboard input or mouse movement for a specified period of time. This will reduce the need to poll the server so frequently.
let heartBeatActivated = false;
class HeartBeat {
constructor() {
document.addEventListener('DOMContentLoaded', () => {
this.initHeartBeat();
});
}
initHeartBeat() {
this.lastActive = new Date().valueOf();
if (!heartBeatActivated) {
['mousemove', 'scroll', 'click', 'keydown'].forEach((activity) => {
document.addEventListener(activity, (ev) => {
this.lastActive = ev.timeStamp + performance.timing.navigationStart;
}, false);
});
heartBeatActivated = true;
}
}
}
window.heartBeat = new HeartBeat();
const sessionTimeoutPollFrequency = 5;
function pollForSessionTimeout() {
if ((Date.now() - window.heartBeat.lastActive) < (sessionTimeoutPollFrequency * 1000)) {
return;
}
let request = new XMLHttpRequest();
request.onload = function (event) {
var status = event.target.status;
var response = event.target.response;
// if the remaining valid time for the current user session is less than or equals to 0 seconds.
if (status === 200 && (response <= 0)) {
window.location.href = '/session_timeout';
}
};
request.open('GET', '/check_session_timeout', true);
request.responseType = 'json';
request.send();
setTimeout(pollForSessionTimeout, (sessionTimeoutPollFrequency * 1000));
}The above code will alleviate load on the server by only polling the server to check if the user session is expired when there has not been any keyboard or mouse input for at least 5 seconds.
Conclusion
And that’s it! Implementing user session timeout based on inactivity is simple if you are using the Devise gem, and we can leverage the Timeoutable module in the gem to build customised solutions for our Rails applications.