| Anthony Bailey ( @ 2008-10-19 20:59:00 |
| Entry tags: | rails, software_development |
Minimal OpenID glue for Rails authentication
The Rails framework and culture offer a well-developed set of plug-ins and patterns for the authentication of users and the authorization of their actions. These are great for more sophisticated enterprise and social applications - but as a hobbyist, my own apps don't tend to need a database full of users and roles. Typically the only user I care about authenticating is me, for write access to content that everyone else should view read-only without any hassle. I want something very lightweight.
The app behind my ego site is a good example. It presents pages based on a few sets of resources - contacts, and various flavors of permalinks for blog entries which are hosted elsewhere. Everybody should be able to access those pages. Behind the scenes there is some simple scaffolding for managing the resources. Only I should be accessing those.
The simplest solution is security through obscurity - to keep quiet about the management URLs. It's not that bad an approach for a low-stakes app, and I did rely on it for a while. Felt wrong, though.
Having decided to do better, I still wanted to keep things as lightweight as I could. My first preference is to minimize the use of the Rails session: I don't want people who visit my public pages to get lumbered with a pointless cookie, so I switch sessions off by default.
One way to keep authenticated session management out of the way of users and apps is to place it in the HTTP layer. I am a big fan of the http_authentication Rails plug-in. The browser on the client side takes care of the session sign-on UI and state, getting users to log in once on first access to protected pages. In your app, you can use a before_filter with implementation as simple as:
authenticate_or_request_with_http_basic do |ignore_user, password| password == 'your secret' end
But, I have a second preference. I don't want arbitrary secrets in my apps, or in my brain. I want single sign-on, please, and my web app just needs to know that I am who I say I am - let someone else do the verification of that identity. And I am already enthusiastic about a technology for authenticating identity in this way - it's OpenID.
Following a "gem install ruby-openid", I still wanted as little Rails glue as possible. The open_id_authentication plug-in is pretty compact given its power, but it does tend to assume that users will live in the database, and it provides all kinds of store machinery that I don't need.
So I cut non-essentials away from the plug-in source and ended up with this minimal parent for my resource controllers.
require 'openid/consumer' class ResourceProtectingController < ApplicationController @@PROTECTED_ACTIONS = %w(new create edit update destroy) session :disabled => false, :only => @@PROTECTED_ACTIONS before_filter :protect, :only => @@PROTECTED_ACTIONS protected def protect return if session[:authenticated] realm = "#{request.protocol + request.host_with_port}" requested_url = "#{realm + request.relative_url_root + request.path}" identity_url = "http://anthonybailey.net" consumer = OpenID::Consumer.new(session, nil) if params[:open_id_complete].nil? open_id_request = consumer.begin(identity_url) open_id_request.return_to_args['open_id_complete'] = '1' redirect_to(open_id_request.redirect_url(realm, requested_url)) else returned_params = params.reject {|k, v| request.path_parameters[k] } returned_params.delete(:format) open_id_response = consumer.complete(returned_params, requested_url) if open_id_response.status != OpenID::Consumer::SUCCESS render :text => "Forbidden: got #{open_id_response.class} " + "on claiming #{identity_url}", :status => 401 else session[:authenticated] = true end end end end
Some notes on the implementation:
- Because OpenID involves a stateful request dance between the relying party and the identity provider, I had to reintroduce the Rails session. However, I can still keep cookies away from the public by only enabling it for the actions needing protection.
- Now I have a session, I ensure that only one dance is required, by stashing the authenticated status. This also works around the fact that OpenID can only return using the GET method: in regular use the client will GET a prelude page before it needs to POST, etc. a real change, at which point we've finished dancing with the provider.
- Mostly as a conceptual indulgence, I left the read-only actions unprotected. (To keep things GET-first, I moved scaffolding UI to delete resources off index pages and onto edit pages - but I kind of feel that's where it should be anyway.)
- Since if you're not me, you're not supposed to be here, I didn't friendly-up the content when access fails.
- Finally, as an aside, it amuses me that my identity URL actually is the domain of the URLs I'm protecting. (I don't run my own identity server, mind - I delegate.)