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