One feature I needed for my education product is a "remember me" option so that frequent users don't have to sign in every time they visit the site.
The simplest implementation of this feature uses persistent cookies. By default, a Rails session uses a non-persistent cookie to pass the session id between a browser and a server, which means that the session ends when the browser is closed. However, if a persistent cookie with an expiry time of, say, 30 days is used instead, a user can re-establish a session even when their browser is closed and reopened several days later.
Before implementing this approach, I searched the web for related code samples. To my surprise, I could not find any examples that used persistent cookies to implement "remember me". Instead, I either found approaches that set *all* sessions to be persistent or approaches that required additional long-term state on the server. Neither suited my purpose, so I started to work on an implementation from scratch.
After about 6 hours of work that involved reading through the source of CGI, CGI::Session, ActionController::CgiRequest and ActionController::CgiResponse, as well as utilizing an HTTP sniffer to see cookies passing between a browser and a server, I finally got it working. And now I know why there are no examples on the web for a straightforward "remember me" implementation; because there is a bug in the Ruby 1.8 CGI::Session implementation that prevents such a thing from being coded.
Before I describe my implementation, I'll outline the way that Rails sessions currently work.
By default, session management is enabled for all actions, which means that the first HTTP request from a particular client causes an associated session to be established. One non-intuitive consequence is that when a user goes to the "sign in" page in order to sign in, their session has already been established.
CgiRequest::session does most of the work associated with establishing a session, including calling CGI::Session.new(). If the session is a new one, CGI::Session.new() allocates it a new session id, otherwise it reuses the existing session id. Then - and this is a bug IMHO - it always adds a Set-Cookie header to pass the session id back to the client. In other words, if a client sends 20 HTTP requests to a server in a single session, its receives 20 Set-Cookie headers from the server instead of just 1.
By default, CGI::Session.new() adds a Set-Cookie with no expiry time to the CgiResponse output cookies, and the only way to override this default is globally using session_options[:session_expires] = <time>. Since I wanted to override the default expiry time on a per-user basis, I looked for a way to access the CgiResponse output cookies so that I could modify their expiry time after CGI::Session had added them.
Unfortunately, there is no API for doing this. Indeed, there is no API for even adding output cookes to a CgiResponse, and CGI::Session adds them by using instance_eval to directly manipulate its instance variables. This violates basic software engineering principles and I was surprised to see this kind of code in the Ruby source code base.
At this point, I tried adding a persistent cookie with the same session id when the user signed on with the "remember me" option in the hope that the browser would give precedence to the persistent version. Here's the code that does this:
if remember_me
cookie = CGI::Cookie::new("name" => '_session_id',
"value" => session.session_id, "path" => '/',
"expires" => (Time.now + 30.days))
response.headers["cookie"] << cookie
end
The result of this code combined with the behavior of CGI::Session is that two cookies were sent back when the user signed in with the "remember me" option; one non-persistent (sent by the CGI::Session code) and one persistent (sent by my code).
At this point, IE 6 seemed to assume that the persistent session cookie outweighed the non-persistent session cookie, and things worked fine. Firefox, on the other hand, seemed to give precedence to the non-persistent cookie and so persistent sessions still didn't work.
To fix this issue I needed to change the behavior of CGI::Session so that it only sent an output cookie when a session is first initiated. With this modification in place, only my persistent cookie is sent which overrides the original non-persistent cookie without ambiguity.
Here's a snippet from a new version of CGI::Session.new() that shows this conditional logic:
if @new_session // only write output cookie for a new session
request.instance_eval do
@output_cookies = [Cookie::new(...)]
end
end
The net result of fixing CGI::Session.new() and sending a persistent session cookie during sign-in is that "remember-me" sign in works fine with both IE and FIrefox. Ultimately, it took just 6 lines of code to accomplish this feature, but 6 hours of hard work to track down which lines had to be written.
Let me know if you'd like me to post any more details of how to implement "remember me".
Recent Comments