Showing posts with label acts_as_authenticated. Show all posts
Showing posts with label acts_as_authenticated. Show all posts

Monday, 6 April 2009

rails gotchas: restful_authentication not 2.0 compliant?

restful_authentication[1] is a great plugin, but the standard svn version is showing it's age. It's a whole month or so out of date... which is, of course, an eternity in the fast-paced world of Rails.

Luckily it's actually just moved home. You can find the up-to-date version on github

[1] Formerly known as acts_as_authenticated

Monday, 30 March 2009

acts_as_state_machine 2.0

I spun up a new rails project and downloaded my favourite authentication plugin restful_authentication, only to find that all out-of-the-box tests break (oh noez!)

Clearly, I thought, restful_authentication is now just a millisecond behind edge rails... and therefore doesn't work properly anymore.

I quickly realised that no, it's just that the old-standard install (via svn) is no longer maintained - you have to move over to the github version.

This solves most of the problems, but I was still having a problem with one of the tests. It seems that acts_as_state_machine is similarly non-Rails 2.0 compliant.

Luckily, acts_as_state_machine has just a one-line fix to get it back up to scratch. It seems that the write_attribute method has been deprecated, and this causes a little bit of internal havoc for the state? style of methods (eg passive?, active? etc as used by restful_authentication).

The test cases kept giving me:

NoMethodError: You have a nil object when you didn't expect it!
The error occurred while evaluating nil.to_sym

To fix it, search for the string: "def set_initial_state", then replace the line (just below it):
write_attribute self.class.state_column, self.class.initial_state.to_s
with:
self.send "#{self.class.state_column}=", self.class.initial_state.to_s

Monday, 28 April 2008

redirect_to POST

Our current site uses the acts_as_authenticated plugin. When a user's session has timed out, we redirect them to the login page, and save the URL they were trying to reach. If they successfully login, we do a redirect to that URL. This all comes as standard in the plugin.

The problem is that redirect_to doesn't seem to preserve the HTTP method (ie GET or POST etc). Most of the time, a user will have clicked on a link, or have a link saved in their bookmarks. The URL will be something like /users/42/orders which would be a a GET-based request that is supposed to show the users current orders. However, sometimes a user will have left their browser session open while they went off to lunch, displaying their set of orders... along with a set of buttons that, say, delete or clone an order.

The problem comes in when this timed-out user tries to click on one of those buttons. A timed-out user that clicks on, say, the "Clone this order" button gets the following error: no route found to match "/user/42/orders/23/clone" with {:method=>:get}

Buttons (which are really a form-post) generally have an HTTP POST action - even the ones with Rails-faked-up PUT or DELETE actions are really POST under the covers. Our nicely RESTful application is set up to not accept GET-based requests for dangerous (non-idempotent) actions such as "clone".

Now, acts_as_authenticated uses two methods for storing/redirecting to URLs. The first is called store_location. The code here grabs the URI that the user requested, and stuffs it into the session. The second is called redirect_back_or_default - it tries to redirect back to the uri stored in the session - or to a given default.

The method: redirect_to is the Rails-standard way of sending you on to another URL via an HTTP redirect. Unfortunately, it seems that this method only tries to use GET-based URIs, so when we tried redirecting to the POST-only URI for the "clone" request, we got the routing error.

At first I thought all that was needed was to save the request's HTTP method, and pass that into redirect_to. Saving the method is easily done, and I stuffed it into the session along with the URI. Then I dug around in vain trying to find out how to pass in the HTTP method as a parameter.

Reading all the doco yielded no joy, nor did digging into the rails core code to see how it deals with uris, sessions, requests or redirection. It wasn't until I delved deep into the HTTP spec itself, and asked a few questions on RoR Oceania that I finally confirmed that it simply isn't "done" to try to redirect a user to anything other than a GET-based URI.

This is somewhat disappointing, as I don't see why a GET has to be treated like something special by HTTP - the use-case for allowing redirection to any other verb is there. :(

Still, there's no getting around this limitation, and the system must fail gracefully (which the routing spew doesn't). So the next best thing is to capture an attempt to access a non-GET request and try something different.

I figured that most of the time when a user tries to get to a non-GET request, it'll be from a button from another page on the site. eg, they'll have left their brower open on the "My orders" page, and have clicked a button from there. It's far less likely they'll somehow have hooked up a bookmark to a POST-based URI, and just as unlikely that some other site will have a button to our site. Therefore, I figued the best option is to try sending the person back to the page they started on (ie the "My orders" page), rather than the page they are asking for (ie whatever button they clicked on).

This can be solved fairly easily. If the user asked for a GET-based request we have no problem, just store it as usual. But if they asked for something else, then we need to save the referer-URI instead. I've also added a flash error to tell the user what happened, and why they have to click the button again.

So the usual acts_as_authenticated code becomes:

    # Store the URI of the current request in the session.
    #
    # We can return to this location by calling #redirect_back_or_default.
    def store_location
      # if the user has asked for a non-get request (eg posted a form). We
      # can't redirect to that - so try getting the referrer (probably the
      # index page they came from). We will post them back to their previous
      # page and warn them about what is going on
      session[:return_get] = request.get?
      session[:return_to] = request.get? ? request.request_uri : request.env["HTTP_REFERER"]
    end
    
    # Redirect to the URI stored by the most recent store_location call or
    # to the passed default.
    def redirect_back_or_default(default)
      # if the user had asked for a non-get request (eg posted a form). We
      # can't redirect to that - so we must warn the user that they will not
      # be going where they were expecting
      flash[:error] = "You clicked on a button or submitted a form, but we cannot redirect you back to that. Please try submitting again." unless session[:return_get]
      redirect_to(session[:return_to] || default)
      session[:return_to] = session[:return_get] = nil
    end