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.
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, authenticate_with_http_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”.
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
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 endI got this idea from Eden Development.
Alternative implementation
Given our triumvirate of HTTP digest authentication methods above, we could rewrite our
authenticatemethod 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 endThe 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 endThis 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' endIt took me a while to realise that
Articleis not visible in thecontext’s scope. You have to pass a string to your custom should method, theninstance_evalit in thesetupblock. (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.