« Learning Science can be a LOT of fun | Main | A New Constitution, Part 2 »

May 28, 2006

Rails Sessions/Remember Me

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

Slippers_14

TrackBack

TrackBack URL for this entry:
http://www.typepad.com/t/trackback/37578/4984029

Listed below are links to weblogs that reference Rails Sessions/Remember Me:

Comments

Wow, that's interesting stuff. But I wonder if you're using too big a hammer. Maybe it would be simpler to have the remember-me cookie be named something like "[app]_user_login", and only use it on the login page to supply auth credentials for the one user. Once the user name and (encrypted) password are supplied to the app, you can use the regular user_id in the non-persistent session to verify that the user is logged in. I think it's good to decouple the login cookie from the session cookie, since sessions can get cleaned up on the server even if the cookie is persistent in the browser.

Two points, possibly related:

I'm not sure what this means, "Indeed, there is no API for even adding output cookes to a CgiResponse." ApplicationController::Cookies does provide an API that allows you to add cookies to a CgiResponse. Maybe I'm not understanding your point.

On whether having Set-Cookie sent on every request a bug, I'd say it was an implementation choice.

It is also the implementation that I prefer as a user and usually as a developer. If your cookie can expire at some fixed time period in the future, then you have to keep updating that time period.
The way I read your code, the session _will_ expire 30 days from now, no matter how many times I visit your site over the next 30 days. This is the approach taken by some sites (gmail, Amazon) but it is annoying to me. If I log in today I would like to never have to log in again if I check your site weekly.

It is just a cookie after all. Sending it 20 times is not that heavy. After all, the user sends it back to you on every request.

Like Josh, I would prefer an approach that decouples the remember me authentication from session persistence. I'd keep persistent state in the db.

I too applaud your zeal for getting to the bottom of your original statement of the problem, but would certainly also recommend that you go the much, much easier route of just using a separate cookie to store a remembered-login.

That's how all the implementations I've done and seen has gone about it. And it's certainly a ton easier to do.

Regarding my comment re: an API for cookies. There *is* an API for adding "regular" cookies to a CgiResponse, but not for what the documentation calls *output* cookies.

If there was indeed an API for adding these so-called output cookies, then the Cgi::Session code would presumably have used it. But instead it uses the hack that I mentioned in my text.

Since all three comments so far have suggested I use a separate cookie for the "remember me" function, I will certainly reconsider that approach.

However, I don't see the essential difference between a session and what "remember me" does. Surely "remember me" simply modulates the length of a session, in which case it seems like you shouldn't need two separate cookies.

As far as the 30-day limitation goes, Ray is right that my code as-is would forget the session length after 30 days. I'm not sure whether I'll keep this behavior (which as he points out some popular sites use), or should adjust it to make it a sliding window. An even simpler approach is to simply set the expiry date to 10 years in the future!

TY so much for the persistant cookie thing, it saved me loads of time for my own rails project

hi Graham,

it's really a good one but i have 1 prob that where i have to put this code that i didn't get. so pls elp me for this as soon as possible n once agian thnx for such a nice stuff.

Graham,

Here is one article using a different cookie.

www.onrails.org/articles/2006/02/18/auto-login

Graham,

Thanks for the interesting info regarding internals. However, I tend to agree with Josh in that the sessions could expire on the server, and we anyways wouldn't want the sessions to be very long-lived.

Here is the complete solution using an auth_token in the database and it is also stored in the database for comparing when the user comes back later.

www.onrails.org/articles/2006/02/18/auto-login

Here's an article explaining Really Simple Remember Me's. 12 lines of code that you can add on to nearly any existing rails login.

http://www.thewojogroup.com/2008/09/remember-mes-with-rails/

http://www.thewojogroup.com/2008/09/remember-mes-with-rails/

For using rails cookies to do a remember me function.

Post a comment

Comments are moderated, and will not appear on this weblog until the author has approved them.

If you have a TypeKey or TypePad account, please Sign In

Destiny

  • Destiny is my science fiction movie about the future of humanity. It's an epic, similar in breadth and scope to 2001: A Space Odyssey.

    To see the 18 minute video, click on the graphic below.

    Destiny17small

People