Anthony Bailey ([info]anthonybailey) wrote,
@ 2008-10-19 20:59:00
Previous Entry  Add to memories!  Tell a Friend  Next Entry
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.)




(11 comments) - (Post a new comment)

openid_wrapper gem
[info]priit.mx.ee
2008-10-21 10:15 am UTC (link)
I wanted clean session controller, but I went a bit different way starting openid_wrapper gem to wrap things up a bit. If you have time, please take a look at http://gitorious.org/projects/openid_wrapper any feedback and merge request are welcome. Its alfa and one of my first gems, so specs are coming later.

(Reply to this) (Thread)

Re: openid_wrapper gem
[info]priit.mx.ee
2008-10-21 10:19 am UTC (link)
ups, correct url: http://gitorious.org/projects/openid-wrapper (http://gitorious.org/projects/openid-wrapper)

(Reply to this) (Parent)(Thread)

Re: openid_wrapper gem
[info]anthonybailey
2008-10-21 06:30 pm UTC (link)
Looks like a nice evolution of open_authentication, and thanks for gemifying!

I don't have any concrete suggestions to move it closer to my use case. I think there were two reasons I rolled my own: partly to pedantically minimize code, and avoid touching the db altogether (albeit at the cost of using stateless OpenID), and partly to play with and try to understand the technology a little.

Cheers!

(Reply to this) (Parent)

How would you go about adding a unit test for this?
[info]http://openid.mangled.me/matthew
2009-03-02 09:37 pm UTC (link)
Anthony, I'm curious. The above doesn't look directly testable (most likely I'm wrong). How did you go about adding a unit test? I ask as I'm starting to play about with OpenID at the minute and any tips in this area would help.

(Reply to this) (Thread)

Re: How would you go about adding a unit test for this?
[info]anthonybailey
2009-03-04 10:21 pm UTC (link)
All my testing for this is in functional tests of the main controller that I protected. I didn't go so far as an integration test against a real or mock OpenID system, just looked to see the correct kind of redirects occurring...
  def test_should_redirect_to_open_id_when_not_authenticated
    assert_redirects_to_open_id { get :new }
    assert_redirects_to_open_id { get :edit, :id => 1 }
  end
...and changed the scaffolded resource tests to set the authenticated flag in the session where appropriate, e.g.
  def test_should_get_new
    authenticated_get :new, {}
    assert_response :success
  end
And down in the test_helpers.rb:
  def assert_redirects_to_open_id
    begin # ruby-openid is irritatingly noisy about stateless mode
      old_stderr = $stderr
      $stderr.reopen(File.open((PLATFORM =~ /mswin/ ? "NUL" : "/dev/null"), 'w'))
      yield
    ensure
      $stderr = old_stderr
    end
    assert_response :redirect
    assert_match /openid/, redirect_to_url
  end

  %w(get post put delete).each do |meth|
    define_method("authenticated_#{meth}") do |symbol, params|
      send meth, symbol, params, { :authenticated => true }
    end
  end



Edited at 2009-03-04 10:40 pm UTC

(Reply to this) (Parent)(Thread)

Re: How would you go about adding a unit test for this?
[info]http://openid.mangled.me/matthew
2009-03-13 06:41 pm UTC (link)
Thanks Anthony,

To be honest I need to learn a bit more about ROR before I can decipher some of this - I can almost see what its doing, getting a little lost on the %w(...) bit. Will play with similar code to understand.

FYI: I managed to get around my problems using mocha, a bit of a cop-out?

(Reply to this) (Parent)(Thread)

Re: How would you go about adding a unit test for this?
[info]anthonybailey
2009-03-13 11:04 pm UTC (link)
%w is just Ruby's version of Perl's qw ("quote words") syntax sugar.
irb(main):001:0> %w(quote these words)
=> ["quote", "these", "words"]

(Reply to this) (Parent)(Thread)

Re: How would you go about adding a unit test for this?
[info]http://openid.mangled.me/matthew
2009-03-14 07:54 pm UTC (link)
It was the use of define_method within the %w() block that I stumbled on.

(Reply to this) (Parent)(Thread)

Re: How would you go about adding a unit test for this?
[info]anthonybailey
2009-03-31 07:51 pm UTC (link)
It's a little meta-programming to implement four similar abbreviations. It has the same result as doing e.g.
def authenticated_get(symbol, params)
  get(symbol, params, :authenticated => true)
end
for each of the four HTTP method types.

(Reply to this) (Parent)

Small patch for Rails 2.2
[info]anthonybailey
2009-03-19 02:15 am UTC (link)
The code in the original post was written for a Rails 2.1 application.

There's a small tweak needed for Rails 2.2: replace request.relative_url_root with (relative_url_root || ''). (Because the method moved from the request to the controller, and now returns nil if there's no root.)

(Reply to this) (Thread)

...and another for Rails 2.3
[info]anthonybailey
2009-03-31 07:45 pm UTC (link)
And at least with the version of openid I have installed (2.1.2) I had to explicitly set OpenID::Util.logger = logger after I upgraded from 2.2 to Rails 2.3 - otherwise the gem doesn't find the Rails logger and dies when it tries to tell you of its successes.

(Reply to this) (Parent)


(11 comments) - (Post a new comment)

Create an Account
Forgot your login or password?
Login w/ OpenID
English • Español • Deutsch • Русский…