lib/httpclient/auth.rb in httpclient-2.1.5.2 vs lib/httpclient/auth.rb in httpclient-2.1.6

- old
+ new

@@ -61,26 +61,29 @@ # It traps HTTP response header and maintains authentication state, and # traps HTTP request header for inserting necessary authentication header. # # WWWAuth has sub filters (BasicAuth, DigestAuth, NegotiateAuth and # SSPINegotiateAuth) and delegates some operations to it. - # NegotiateAuth requires 'ruby/ntlm' module. - # SSPINegotiateAuth requires 'win32/sspi' module. + # NegotiateAuth requires 'ruby/ntlm' module (rubyntlm gem). + # SSPINegotiateAuth requires 'win32/sspi' module (rubysspi gem). class WWWAuth < AuthFilterBase attr_reader :basic_auth attr_reader :digest_auth attr_reader :negotiate_auth attr_reader :sspi_negotiate_auth + attr_reader :oauth # Creates new WWWAuth. def initialize @basic_auth = BasicAuth.new @digest_auth = DigestAuth.new @negotiate_auth = NegotiateAuth.new + @ntlm_auth = NegotiateAuth.new('NTLM') @sspi_negotiate_auth = SSPINegotiateAuth.new + @oauth = OAuth.new # sort authenticators by priority - @authenticator = [@negotiate_auth, @sspi_negotiate_auth, @digest_auth, @basic_auth] + @authenticator = [@oauth, @negotiate_auth, @ntlm_auth, @sspi_negotiate_auth, @digest_auth, @basic_auth] end # Resets challenge state. See sub filters for more details. def reset_challenge @authenticator.each do |auth| @@ -149,13 +152,14 @@ # Creates new ProxyAuth. def initialize @basic_auth = BasicAuth.new @negotiate_auth = NegotiateAuth.new + @ntlm_auth = NegotiateAuth.new('NTLM') @sspi_negotiate_auth = SSPINegotiateAuth.new # sort authenticators by priority - @authenticator = [@negotiate_auth, @sspi_negotiate_auth, @basic_auth] + @authenticator = [@negotiate_auth, @ntlm_auth, @sspi_negotiate_auth, @basic_auth] end # Resets challenge state. See sub filters for more details. def reset_challenge @authenticator.each do |auth| @@ -205,10 +209,12 @@ end # Authentication filter for handling BasicAuth negotiation. # Used in WWWAuth and ProxyAuth. class BasicAuth + include HTTPClient::Util + # Authentication scheme. attr_reader :scheme # Creates new BasicAuth filter. def initialize @@ -249,12 +255,12 @@ Util.uri_part_of(target_uri, uri) } end # Challenge handler: remember URL for response. - def challenge(uri, param_str) - @challengeable[uri] = true + def challenge(uri, param_str = nil) + @challengeable[urify(uri)] = true true end end @@ -299,12 +305,11 @@ return nil unless param user, passwd = Util.hash_find_value(@auth) { |uri, auth_data| Util.uri_part_of(target_uri, uri) } return nil unless user - uri = req.header.request_uri - calc_cred(req.header.request_method, uri, user, passwd, param) + calc_cred(req, user, passwd, param) end # Challenge handler: remember URL and challenge token for response. def challenge(uri, param_str) @challenge[uri] = parse_challenge_param(param_str) @@ -315,13 +320,15 @@ # this method is implemented by sromano and posted to # http://tools.assembla.com/breakout/wiki/DigestForSoap # Thanks! # supported algorithm: MD5 only for now - def calc_cred(method, uri, user, passwd, param) + def calc_cred(req, user, passwd, param) + method = req.header.request_method + path = req.header.create_query_uri a_1 = "#{user}:#{param['realm']}:#{passwd}" - a_2 = "#{method}:#{uri.path}" + a_2 = "#{method}:#{path}" nonce = param['nonce'] cnonce = generate_cnonce() @nonce_count += 1 message_digest = [] message_digest << Digest::MD5.hexdigest(a_1) @@ -332,16 +339,16 @@ message_digest << Digest::MD5.hexdigest(a_2) header = [] header << "username=\"#{user}\"" header << "realm=\"#{param['realm']}\"" header << "nonce=\"#{nonce}\"" - header << "uri=\"#{uri.path}\"" + header << "uri=\"#{path}\"" header << "cnonce=\"#{cnonce}\"" header << "nc=#{'%08x' % @nonce_count}" - header << "qop=\"#{param['qop']}\"" + header << "qop=#{param['qop']}" header << "response=\"#{Digest::MD5.hexdigest(message_digest.join(":"))}\"" - header << "algorithm=\"MD5\"" + header << "algorithm=MD5" header << "opaque=\"#{param['opaque']}\"" if param.key?('opaque') header.join(", ") end # cf. WEBrick::HTTPAuth::DigestAuth#generate_next_nonce(aTime) @@ -374,15 +381,15 @@ attr_reader :scheme # NTLM opt for ruby/ntlm. {:ntlmv2 => true} by default. attr_reader :ntlm_opt # Creates new NegotiateAuth filter. - def initialize + def initialize(scheme = "Negotiate") @auth = {} @auth_default = nil @challenge = {} - @scheme = "Negotiate" + @scheme = scheme @ntlm_opt = { :ntlmv2 => true } end @@ -513,9 +520,247 @@ c = @challenge[uri] c[:state] = :response c[:authphrase] = param_str end true + end + end + + # Authentication filter for handling OAuth negotiation. + # Used in WWWAuth. + # + # CAUTION: This impl only support '#7 Accessing Protected Resources' in OAuth + # Core 1.0 spec for now. You need to obtain Access token and Access secret by + # yourself. + # + # CAUTION: This impl does NOT support OAuth Request Body Hash spec for now. + # http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html + # + class OAuth + include HTTPClient::Util + + # Authentication scheme. + attr_reader :scheme + + class Config + include HTTPClient::Util + + attr_accessor :http_method + attr_accessor :realm + attr_accessor :consumer_key + attr_accessor :consumer_secret + attr_accessor :token + attr_accessor :secret + attr_accessor :signature_method + attr_accessor :version + attr_accessor :callback + attr_accessor :verifier + + # for OAuth Session 1.0 (draft) + attr_accessor :session_handle + + attr_reader :signature_handler + + attr_accessor :debug_timestamp + attr_accessor :debug_nonce + + def initialize(*args) + @http_method, + @realm, + @consumer_key, + @consumer_secret, + @token, + @secret, + @signature_method, + @version, + @callback, + @verifier = + keyword_argument(args, + :http_method, + :realm, + :consumer_key, + :consumer_secret, + :token, + :secret, + :signature_method, + :version, + :callback, + :verifier + ) + @http_method ||= :post + @session_handle = nil + @signature_handler = {} + end + end + + def self.escape(str) # :nodoc: + if str.respond_to?(:force_encoding) + str.dup.force_encoding('BINARY').gsub(/([^a-zA-Z0-9_.~-]+)/) { + '%' + $1.unpack('H2' * $1.bytesize).join('%').upcase + } + else + str.gsub(/([^a-zA-Z0-9_.~-]+)/n) { + '%' + $1.unpack('H2' * $1.bytesize).join('%').upcase + } + end + end + + def escape(str) + self.class.escape(str) + end + + # Creates new DigestAuth filter. + def initialize + @config = nil # common config + @auth = {} # configs for each site + @challengeable = {} + @nonce_count = 0 + @signature_handler = { + 'HMAC-SHA1' => method(:sign_hmac_sha1) + } + @scheme = "OAuth" + end + + # Resets challenge state. Do not send '*Authorization' header until the + # server sends '*Authentication' again. + def reset_challenge + @challengeable.clear + end + + # Set authentication credential. + # You cannot set OAuth config via WWWAuth#set_auth. Use OAuth#config= + def set(uri, user, passwd) + # not supported + end + + # Set authentication credential. + def set_config(uri, config) + if uri.nil? + @config = config + else + uri = Util.uri_dirname(urify(uri)) + @auth[uri] = config + end + end + + # Get authentication credential. + def get_config(uri = nil) + if uri.nil? + @config + else + uri = urify(uri) + Util.hash_find_value(@auth) { |cand_uri, cred| + Util.uri_part_of(uri, cand_uri) + } + end + end + + # Response handler: returns credential. + # It sends cred only when a given uri is; + # * child page of challengeable(got *Authenticate before) uri and, + # * child page of defined credential + def get(req) + target_uri = req.header.request_uri + return nil unless @challengeable[nil] or @challengeable.find { |uri, ok| + Util.uri_part_of(target_uri, uri) and ok + } + config = get_config(target_uri) || @config + return nil unless config + calc_cred(req, config) + end + + # Challenge handler: remember URL for response. + def challenge(uri, param_str = nil) + if uri.nil? + @challengeable[nil] = true + else + @challengeable[urify(uri)] = true + end + true + end + + private + + def calc_cred(req, config) + header = {} + header['oauth_consumer_key'] = config.consumer_key + header['oauth_token'] = config.token + header['oauth_signature_method'] = config.signature_method + header['oauth_timestamp'] = config.debug_timestamp || Time.now.to_i.to_s + header['oauth_nonce'] = config.debug_nonce || generate_nonce() + header['oauth_version'] = config.version if config.version + header['oauth_callback'] = config.callback if config.callback + header['oauth_verifier'] = config.verifier if config.verifier + header['oauth_session_handle'] = config.session_handle if config.session_handle + signature = sign(config, header, req) + header['oauth_signature'] = signature + # no need to do but we should sort for easier to test. + str = header.sort_by { |k, v| k }.map { |k, v| encode_header(k, v) }.join(', ') + if config.realm + str = %Q(realm="#{config.realm}", ) + str + end + str + end + + def generate_nonce + @nonce_count += 1 + now = "%012d" % Time.now.to_i + pk = Digest::MD5.hexdigest([@nonce_count.to_s, now, self.__id__, Process.pid, rand(65535)].join)[0, 32] + [now + ':' + pk].pack('m*').chop + end + + def encode_header(k, v) + %Q(#{escape(k.to_s)}="#{escape(v.to_s)}") + end + + def encode_param(params) + params.map { |k, v| + [v].flatten.map { |vv| + %Q(#{escape(k.to_s)}=#{escape(vv.to_s)}) + } + }.flatten + end + + def sign(config, header, req) + base_string = create_base_string(config, header, req) + if handler = config.signature_handler[config.signature_method] || @signature_handler[config.signature_method.to_s] + handler.call(config, base_string) + else + raise ConfigurationError.new("Unknown OAuth signature method: #{config.signature_method}") + end + end + + def create_base_string(config, header, req) + params = encode_param(header) + query = req.header.request_query + if query and HTTP::Message.multiparam_query?(query) + params += encode_param(query) + end + # captures HTTP Message body only for 'application/x-www-form-urlencoded' + if req.header.contenttype == 'application/x-www-form-urlencoded' and req.body.size + params += encode_param(HTTP::Message.parse(req.body.content)) + end + uri = req.header.request_uri + if uri.query + params += encode_param(HTTP::Message.parse(uri.query)) + end + if uri.port == uri.default_port + request_url = "#{uri.scheme.downcase}://#{uri.host}#{uri.path}" + else + request_url = "#{uri.scheme.downcase}://#{uri.host}:#{uri.port}#{uri.path}" + end + [req.header.request_method.upcase, request_url, params.sort.join('&')].map { |e| + escape(e) + }.join('&') + end + + def sign_hmac_sha1(config, base_string) + unless SSLEnabled + raise ConfigurationError.new("openssl required for OAuth implementation") + end + key = [escape(config.consumer_secret.to_s), escape(config.secret.to_s)].join('&') + digester = OpenSSL::Digest::SHA1.new + [OpenSSL::HMAC.digest(digester, key, base_string)].pack('m*').chomp end end end