Sorry this page looks weird. It was automatically migrated from my old blog, which had a different layout and different CSS.

Testing HTTP Digest Authentication With Shoulda

I recently started writing a small Rails app where I’ll be the only administrative user. (It’s going to replace Mephisto, which runs this blog and regularly goes rogue on the CPU.) Anyway, my authentication requirements were simple: log in, log out. So I asked myself, “what’s the simplest thing that could possibly work?”

Well, HTTP authentication is supposed to be simpler than a form-based approach. In theory it’s trivial to code: just copy and paste the example from the Rails documentation. In practice there were a few catches which took me quite a while to resolve, not least the testing.

Once I had settled on HTTP authentication I had to choose between basic and digest. I picked digest because I thought basic and digest would be similarly easy to implement, so I might as well use the securer version.

Understanding Rails' API

I hadn’t implemented HTTP authentication before. My first problem was finding Rails' API confusing. There are three methods:

  authenticate_or_request_with_http_digest(realm = 'Application', &password_procedure)

  authenticate_with_http_digest(realm = 'Application', &password_procedure)

  request_http_digest_authentication(realm = 'Application', message = nil)

I found them easier to understand in reverse order.

The last one renders a 401 to the browser, along with headers saying ‘use HTTP digest authentication to authenticate’. It’s not actually making a request; it’s instructing the browser to request the resource again, but this time with the user’s name and password bundled up as per the HTTP-digest protocol. The method returns the message, which defaults to "HTTP Digest: Access denied.\n".

The browser sees the 401 and the ‘use HTTP digest authentication’ and pops up a dialog box for the user asking for a name and password. Once the user has typed these in, the browser sends a new request for the resource, but this time with the user’s log-in details too.

The middle one, authenticatewithhttp_digest, checks whatever name and password the browser supplies to see if they’re legitimate. It simply returns true or false. It doesn’t know or care whether the browser has just asked the user to type in a name and password, or whether the browser cached whatever legitimate credentials the user typed in previously.

Now we know what those methods do, we can understand the first one. It simply wraps them:

  authenticate_with_http_digest(realm, &password_procedure) ||
  request_http_digest_authentication(realm)

So first it checks whether the user’s name and password, as sent by the browser, are legitimate. If the browser had these in a cache, it won’t have asked the user again. If the user’s credentials are good, the method returns true. Otherwise — either the browser didn’t send any credentials or it did but the credentials were bad — we send a 401 to the browser, causing it to ask the user for a name and password, and then to re-request the resource. In this case the method returns "HTTP Digest: Access denied.\n".

Storing the user in the session

Bearing in mind a gap in Rails 2.3.2’s implementation, we can store the user in the session like this:

def authenticate
  result = authenticate_or_request_with_http_digest do |name|
    @user = User.find_by_name(name)
    @user.try(:password) || false
  end
  session[:current_user] = @user.id unless result =~ /denied/
end

Log out with HTTP digest

Since the browser caches the user’s name and password, we need a way to force the browser to forget them. I use a flag which tells my authenticate method to make the authentication fail:

class SessionsController < ApplicationController
  def logout
    session[:current_user] = nil       # so the app knows
    session[:logout_requested] = true  # so the browser will know
    redirect_to root_path
  end
end

class ApplicationController < ActionController::Base
  def authenticate
    result = authenticate_or_request_with_http_digest do |name|
      if session[:logout_requested]
        session[:logout_requested] = nil   # reset flag
        false
      else
        @user = User.find_by_name(name)
        @user.try(:password) || false
      end
    end
    session[:current_user] = @user.id unless result =~ /denied/
  end
end


I got this idea from Eden Development.

Alternative implementation

Given our triumvirate of HTTP digest authentication methods above, we could rewrite our authenticate method in terms of the two lower-level methods like this:

class ApplicationController < ActionController::Base
  def authenticate
    success = authenticate_with_http_digest do |name|
      if session[:logout_requested]
        session[:logout_requested] = nil   # reset flag
        false
      else
        @user = User.find_by_name(name)
        @user.try(:password) || false
      end
    end

    if success
      session[:current_user] = @user.id
    else
      request_http_digest_authentication
    end
  end
end


The Tests

Testing HTTP digest authentication is not easy. Fortunately Steve Madsen figured it out . I incorporated his code into this Shoulda macro:

# test/shoulda_macros/security.rb:

module AirWeb
  module Shoulda

    def should_be_unauthorized(http_method, action, options = {})
      context "on #{http_method} to #{action}" do
        setup do
          options.each{ |k,v| options[k] = instance_eval(v) }
          send http_method, action, options
        end
        should_respond_with :unauthorized
      end
    end

  end
end

module AirWeb
  module Shoulda
    module Helpers

      def log_in
        # Any user will do.
        authenticate_with_http_digest(User.first.name, User.first.password)
        @request.session[:current_user] = User.first.id
        @request.session[:logout_requested ] = false
      end

      def log_out
        @request.session[:current_user] = nil
        @request.session[:logout_requested ] = true
      end

    end
  end
end

ActiveSupport::TestCase.send :include, AirWeb::Shoulda::Helpers
ActiveSupport::TestCase.extend AirWeb::Shoulda

require 'digest/md5'
 
class ActionController::TestCase
  def authenticate_with_http_digest(user = 'admin', password = 'admin', realm = 'Application')
    unless ActionController::Base < ActionController::ProcessWithTest
      ActionController::Base.class_eval { include ActionController::ProcessWithTest }
    end
 
    @controller.instance_eval %Q(
      alias real_process_with_test process_with_test
 
      def process_with_test(request, response)
        credentials = {
          :uri => request.env['REQUEST_URI'],
          :realm => "#{realm}",
          :username => "#{user}",
          :nonce => ActionController::HttpAuthentication::Digest.nonce,
          :opaque => ActionController::HttpAuthentication::Digest.opaque,
        }
        request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Digest.encode_credentials(
          request.request_method, credentials, "#{password}", false
        )
        real_process_with_test(request, response)
      end
    )
  end
end

This allows me to write test code like:

  context 'The public' do
    setup { log_out }
    should_be_unauthorized :get, :new
    should_be_unauthorized :create, :new
    should_be_unauthorized :update, 'Article.first.id'
  end

It took me a while to realise that Article is not visible in the context’s scope. You have to pass a string to your custom should method, then instance_eval it in the setup block. (Or you could pass an instance variable.)

PUT and DELETE problem with HTTP digest

In the course of all this, I realised that my app wouldn’t allow me to update (via PUT) or destroy (via DELETE). It turns out there was a bug but it has been fixed in commit dbb025 by Steve Madsen. This is on the 2-3-stable branch but not in the gems, so you’ll have to vendor Rails.

Conclusion

I rather wish I had stuck with form-based authentication! However that’s because I hadn’t looked at HTTP authentication before. Now I have, the next time I come to implement authentication, HTTP authentication actually will be the simplest thing that could work.

Andrew Stewart • 9 June 2009 • Rails
You can reach me by email or on Twitter.

© 2014 AirBlade Software. All rights reserved.