Persisting a user session from RoR->PHP

Posted by Nicholas Thu, 10 Apr 2008 18:28:00 GMT

Recently I was given the task of integrating a (complete for all intents and purposes) PHP application with our main Ruby on Rails application. Because the PHP application needed to display a similar interface and required knowledge of the user’s account, I needed a way to access that data from the database both applications were now sharing. The only real requirement I had was that I absolutely didn’t want to make the user login to the PHP app if they were already authenticated on the Rails side of things as this seemed unnecessary and interrupted the flow things.

I did a bit of searching and, while I did find the wiki page on going from PHP -> Rails, I wasn’t able to find anything that fit my specific need, so I set out to roll my own. I read an article this morning from somebody who had ostensibly encountered the same problem as I, and was able to come up with a much different solution than what I had come up with. Therefore I thought it would be fun to share some of the details of my approach.

The first way I thought of doing this was to post the session key across to the PHP app and then just do a lookup in the sessions table. This approach was unwieldy as it required me to expose the session key and it would also mean that users would be forced to enter the PHP portion from our provided links. Direct linking would simply not work. Thankfully I didn’t have to worry about this since we’re now using the fancy shmancy new CookieStore instead of the previously recommended ActiveRecordStore.

Now I just had to figure out how to read in the cookies. I took a quick look to see how Rails was storing the data in the cookie:
     # File vendor/rails/actionpack/lib/action_controller/session/cookie_store.rb, line 131
131:     def marshal(session)
132:       data = ActiveSupport::Base64.encode64(Marshal.dump(session)).chop
133:       CGI.escape "#{data}--#{generate_digest(data)}" 
134:     end
    # File vendor/rails/actionpack/lib/action_controller/session/cookie_store.rb, line 124
124:   def generate_digest(data)
125:     key = @secret.respond_to?(:call) ? @secret.call(@session) : @secret
126:     OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new(@digest), key, data)
127:   end

Ok, so this meant all I had to was urldecode the cookie, base64 decode the cookie and hash the data so that I could compare the integrity hash passed through the cookie so I knew that I could trust the content. Since the cookies contained a hash from with the secret salt from our environment.rb, I had to make this manually make the PHP side aware of this. How you go about doing this is up to you, for this example let’s assume I just copied the secret salt from our Rails environment and stuck it in a constant called SECRET_SALT. This was all pretty trivial from the php side of things:

function parse_data_from_cookie( $cookie_name ) {
    $decoded_cookie = urldecode( $_COOKIE[ $cookie_name ] );

    // the cookie is passed through as data--digest, so split these out into their on variables
    list( $encoded_payload, $supplied_digest ) = explode( "--", $decoded_cookie );

    // construct a sha1 integrity hash with the data passed in the cookie and our secret salt
    $generated_digest = hash_hmac( 'sha1', $encoded_payload, SECRET_SALT );

    // can we trust that the data in this cookie came from our rails app and not from some malicious user?
    if ( $supplied_digest == $generated_digest ) {
        $data = base64_decode( $encoded_payload );
        return $data;
    // guess not
    } else {
        return false;
    }
}

This was working just as you would expect, however there was one caveat that you may have already anticipated. The data passed through in the cookie was actually marshaled Ruby. Because I really only cared about the user_id and none of the other cruft, the simpliest work around was to construct a custom cookie that only contained the user’s id and that was not marshaled. I did this by making a simple method that would allow me to set all of the custom cookies I want, using the same digest generation and encryption process as the CookieStore was doing. I ended up with something a lot like this:

def set_custom_cookie( key, value )
  data = ActiveSupport::Base64.encode64( value.to_s )
  digest = session.dbman.generate_digest( data )
  cookies[ key ] = CGI.escape( "#{ data }--#{ digest }" )
end

Note that I’m using session.dbman to grab the CookieStore instance so that I can reuse the generate_digest method that handles all of the lightwork for me.

Now when a user logs in, I just set an additional cookie with that user’s id in a custom cookie for the PHP side of things. When the user logs out I just delete this cookie. I actually manage the life of this cookie in a slightly different way, but that process is left as an for the reader. On the PHP side of things, if my cookie contains no data, if the digests don’t match, or if I can’t find the user in the database matching the passed user_id then I simply redirect them to our login page on the main app.

Enjoy.

Comments

(leave url/email »)