# Copyright (c) 2008 The Kaphan Foundation # # See License.txt for licensing information. # $:.unshift(File.dirname(__FILE__)) unless $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__))) require 'openssl' require 'base64' # This module provides a HMAC Authentication method for HTTP requests. It should work with # net/http request classes and CGIRequest classes and hence Rails. # # It is loosely based on the Amazon Web Services Authentication mechanism but # generalized to be useful to any application that requires HMAC based authentication. # As a result of the generalization, it won't work with AWS because it doesn't support # the Amazon extension headers. # # === References # Cryptographic Hash functions:: http://en.wikipedia.org/wiki/Cryptographic_hash_function # SHA-1 Hash function:: http://en.wikipedia.org/wiki/SHA-1 # HMAC algorithm:: http://en.wikipedia.org/wiki/HMAC # RFC 2104:: http://tools.ietf.org/html/rfc2104 # class AuthHMAC module Headers # :nodoc: # Gets the headers for a request. # # Attempts to deal with known HTTP header representations in Ruby. # Currently handles net/http and Rails. # def headers(request) if request.respond_to?(:[]) request elsif request.respond_to?(:headers) request.headers else raise ArgumentError, "Don't know how to get the headers from #{request.inspect}" end end def find_header(keys, headers) keys.map do |key| headers[key] end.compact.first end end include Headers # Build a Canonical String for a HTTP request. # # A Canonical String has the following format: # # CanonicalString = HTTP-Verb + "\n" + # Content-Type + "\n" + # Content-MD5 + "\n" + # Date + "\n" + # request-uri; # # # If the Date header doesn't exist, one will be generated since # Net/HTTP will generate one if it doesn't exist and it will be # used on the server side to do authentication. # class CanonicalString < String # :nodoc: include Headers def initialize(request) self << request_method(request) + "\n" self << header_values(headers(request)) + "\n" self << request_path(request) end private def request_method(request) if request.respond_to?(:request_method) && request.request_method.is_a?(String) request.request_method elsif request.respond_to?(:method) && request.method.is_a?(String) request.method elsif request.respond_to?(:env) && request.env request.env['REQUEST_METHOD'] else raise ArgumentError, "Don't know how to get the request method from #{request.inspect}" end end def header_values(headers) [ content_type(headers), content_md5(headers), (date(headers) or headers['Date'] = Time.now.utc.httpdate) ].join("\n") end def content_type(headers) find_header(%w(CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE), headers) end def date(headers) find_header(%w(DATE HTTP_DATE), headers) end def content_md5(headers) find_header(%w(CONTENT-MD5 CONTENT_MD5), headers) end def request_path(request) # Try unparsed_uri in case it is a Webrick request path = if request.respond_to?(:unparsed_uri) request.unparsed_uri else request.path end path[/^[^?]*/] end end @@default_signature_class = CanonicalString # Create an AuthHMAC instance using the given credential store # # Credential Store: # * Credential store must respond to the [] method and return a secret for access key id # # Options: # Override default options # * :service_id - Service ID used in the AUTHORIZATION header string. Default is AuthHMAC. # * :signature_method - Proc object that takes request and produces the signature string # used for authentication. Default is CanonicalString. # Examples: # my_hmac = AuthHMAC.new('access_id1' => 'secret1', 'access_id2' => 'secret2') # # cred_store = { 'access_id1' => 'secret1', 'access_id2' => 'secret2' } # options = { :service_id => 'MyApp', :signature_method => lambda { |r| MyRequestString.new(r) } } # my_hmac = AuthHMAC.new(cred_store, options) # def initialize(credential_store, options = nil) @credential_store = credential_store # Defaults @service_id = self.class.name @signature_class = @@default_signature_class unless options.nil? @service_id = options[:service_id] if options.key?(:service_id) @signature_class = options[:signature] if options.key?(:signature) && options[:signature].is_a?(Class) end @signature_method = lambda { |r| @signature_class.send(:new, r) } end # Generates canonical signing string for given request # # Supports same options as AuthHMAC.initialize for overriding service_id and # signature method. # def AuthHMAC.canonical_string(request, options = nil) self.new(nil, options).canonical_string(request) end # Generates signature string for a given secret # # Supports same options as AuthHMAC.initialize for overriding service_id and # signature method. # def AuthHMAC.signature(request, secret, options = nil) self.new(nil, options).signature(request, secret) end # Signs a request using a given access key id and secret. # # Supports same options as AuthHMAC.initialize for overriding service_id and # signature method. # def AuthHMAC.sign!(request, access_key_id, secret, options = nil) credentials = { access_key_id => secret } self.new(credentials, options).sign!(request, access_key_id) end # Authenticates a request using HMAC # # Supports same options as AuthHMAC.initialize for overriding service_id and # signature method. # def AuthHMAC.authenticated?(request, access_key_id, secret, options) credentials = { access_key_id => secret } self.new(credentials, options).authenticated?(request) end # Signs a request using the access_key_id and the secret associated with that id # in the credential store. # # Signing a requests adds an Authorization header to the request in the format: # # : # # where is the Base64 encoded HMAC-SHA1 of the CanonicalString and the secret. # def sign!(request, access_key_id) secret = @credential_store[access_key_id] raise ArgumentError, "No secret found for key id '#{access_key_id}'" if secret.nil? request['Authorization'] = authorization(request, access_key_id, secret) end # Authenticates a request using HMAC # # Returns true if the request has an AuthHMAC Authorization header and # the access id and HMAC match an id and HMAC produced for the secret # in the credential store. Otherwise returns false. # def authenticated?(request) rx = Regexp.new("#{@service_id} ([^:]+):(.+)$") if md = rx.match(authorization_header(request)) access_key_id = md[1] hmac = md[2] secret = @credential_store[access_key_id] !secret.nil? && hmac == signature(request, secret) else false end end def signature(request, secret) digest = OpenSSL::Digest::Digest.new('sha1') Base64.encode64(OpenSSL::HMAC.digest(digest, secret, canonical_string(request))).strip end def canonical_string(request) @signature_method.call(request) end def authorization_header(request) find_header(%w(Authorization HTTP_AUTHORIZATION), headers(request)) end def authorization(request, access_key_id, secret) "#{@service_id} #{access_key_id}:#{signature(request, secret)}" end # Integration with Rails # class Rails # :nodoc: module ControllerFilter # :nodoc: module ClassMethods # Call within a Rails Controller to initialize HMAC authentication for the controller. # # * +credentials+ must be a hash that indexes secrets by their access key id. # * +options+ supports the following arguments: # * +failure_message+: The text to use when authentication fails. # * +only+: A list off actions to protect. # * +except+: A list of actions to not protect. # * +hmac+: Options for HMAC creation. See AuthHMAC#initialize for options. # def with_auth_hmac(credentials, options = {}) unless credentials.nil? self.credentials = credentials self.authhmac_failure_message = (options.delete(:failure_message) or "HMAC Authentication failed") self.authhmac = AuthHMAC.new(self.credentials, options.delete(:hmac)) before_filter(:hmac_login_required, options) else $stderr.puts("with_auth_hmac called with nil credentials - authentication will be skipped") end end end module InstanceMethods # :nodoc: def hmac_login_required unless hmac_authenticated? response.headers['WWW-Authenticate'] = 'AuthHMAC' render :text => self.class.authhmac_failure_message, :status => :unauthorized end end def hmac_authenticated? self.class.authhmac.nil? ? true : self.class.authhmac.authenticated?(request) end end unless defined?(ActionController) begin require 'rubygems' gem 'actionpack' gem 'activesupport' require 'action_controller' require 'active_support' rescue nil end end if defined?(ActionController::Base) ActionController::Base.class_eval do class_inheritable_accessor :authhmac class_inheritable_accessor :credentials class_inheritable_accessor :authhmac_failure_message end ActionController::Base.send(:include, ControllerFilter::InstanceMethods) ActionController::Base.extend(ControllerFilter::ClassMethods) end end module ActiveResourceExtension # :nodoc: module BaseHmac # :nodoc: def self.included(base) base.extend(ClassMethods) base.class_inheritable_accessor :hmac_access_id base.class_inheritable_accessor :hmac_secret base.class_inheritable_accessor :use_hmac base.class_inheritable_accessor :hmac_options end module ClassMethods # Call with an Active Resource class definition to sign # all HTTP requests sent by that class with the provided # credentials. # # Can be called with either a hash or two separate parameters # like so: # # class MyResource < ActiveResource::Base # with_auth_hmac("my_access_id", "my_secret") # end # # or # # class MyOtherResource < ActiveResource::Base # with_auth_hmac("my_access_id" => "my_secret") # end # # # This has only been tested with Rails 2.1 and since it is virtually a monkey # patch of the internals of ActiveResource it might not work with past or # future versions. # def with_auth_hmac(access_id, secret = nil, options = nil) if access_id.is_a?(Hash) self.hmac_access_id = access_id.keys.first self.hmac_secret = access_id[self.hmac_access_id] else self.hmac_access_id = access_id self.hmac_secret = secret end self.use_hmac = true self.hmac_options = options class << self alias_method_chain :connection, :hmac end end def connection_with_hmac(refresh = false) # :nodoc: c = connection_without_hmac(refresh) c.hmac_access_id = self.hmac_access_id c.hmac_secret = self.hmac_secret c.use_hmac = self.use_hmac c.hmac_options = self.hmac_options c end end module InstanceMethods # :nodoc: end end module Connection # :nodoc: def self.included(base) base.send :alias_method_chain, :request, :hmac base.class_eval do attr_accessor :hmac_secret, :hmac_access_id, :use_hmac, :hmac_options end end def request_with_hmac(method, path, *arguments) if use_hmac && hmac_access_id && hmac_secret arguments.last['Date'] = Time.now.httpdate if arguments.last['Date'].nil? temp = "Net::HTTP::#{method.to_s.capitalize}".constantize.new(path, arguments.last) AuthHMAC.sign!(temp, hmac_access_id, hmac_secret, hmac_options) arguments.last['Authorization'] = temp['Authorization'] end request_without_hmac(method, path, *arguments) end end unless defined?(ActiveResource) begin require 'rubygems' gem 'activeresource' require 'activeresource' rescue nil end end if defined?(ActiveResource) ActiveResource::Base.send(:include, BaseHmac) ActiveResource::Connection.send(:include, Connection) end end end end