Repository URL to install this package:
|
Version:
2.0.7.4 ▾
|
require "net/https"
require "digest"
# Avoid execution timeout for host due to DNS delays using system resolver from libc. https://github.com/ruby/ruby/pull/597#issuecomment-40507119
require "resolv-replace"
module RETS
class HTTP
attr_accessor :login_uri
##
# Creates a new HTTP instance which will automatically handle authenting to the RETS server.
def initialize(args)
@headers = {"User-Agent" => "Ruby RETS/v#{RETS::VERSION}"}
@request_count = 0
@config = {:http => {}}.merge(args)
@rets_data, @cookie_list = {}, {}
if @config[:useragent] and @config[:useragent][:name]
@headers["User-Agent"] = @config[:useragent][:name]
end
if @config[:rets_version]
@rets_data[:version] = @config[:rets_version]
self.setup_ua_authorization(:version => @config[:rets_version])
end
if @config[:auth_mode] == :basic
@auth_mode = @config.delete(:auth_mode)
end
end
def get_digest(header)
return unless header
header.each do |text|
mode, text = text.split(" ", 2)
return text if mode == "Digest"
end
nil
end
##
# Creates and manages the HTTP digest auth
# if the WWW-Authorization header is passed, then it will overwrite what it knows about the auth data.
def save_digest(header)
@request_count = 0
@digest = {}
header.split(",").each do |line|
k, v = line.strip.split("=", 2)
@digest[k] = (k != "algorithm" and k != "stale") && v[1..-2] || v
end
@digest_type = @digest["qop"] ? @digest["qop"].split(",") : []
end
##
# Creates a HTTP digest header.
def create_digest(method, request_uri)
# http://en.wikipedia.org/wiki/Digest_access_authentication
first = Digest::MD5.hexdigest("#{@config[:username]}:#{@digest["realm"]}:#{@config[:password]}")
second = Digest::MD5.hexdigest("#{method}:#{request_uri}")
# Using the "newer" authentication QOP
if @digest_type.include?("auth")
cnonce = Digest::MD5.hexdigest("#{@headers["User-Agent"]}:#{@config[:password]}:#{@request_count}:#{@digest["nonce"]}")
hash = Digest::MD5.hexdigest("#{first}:#{@digest["nonce"]}:#{"%08X" % @request_count}:#{cnonce}:#{@digest["qop"]}:#{second}")
# Nothing specified, so default to the old one
elsif @digest_type.empty?
hash = Digest::MD5.hexdigest("#{first}:#{@digest["nonce"]}:#{second}")
else
raise RETS::HTTPError, "Cannot determine auth type for server (#{@digest_type.join(",")})"
end
http_digest = "Digest username=\"#{@config[:username]}\", "
http_digest << "realm=\"#{@digest["realm"]}\", "
http_digest << "nonce=\"#{@digest["nonce"]}\", "
http_digest << "uri=\"#{request_uri}\", "
http_digest << "algorithm=MD5, " unless @digest_type.empty?
http_digest << "response=\"#{hash}\", "
http_digest << "opaque=\"#{@digest["opaque"]}\""
unless @digest_type.empty?
http_digest << ", "
http_digest << "qop=\"#{@digest["qop"]}\", "
http_digest << "nc=#{"%08X" % @request_count}, "
http_digest << "cnonce=\"#{cnonce}\""
end
http_digest
end
##
# Creates a HTTP basic header.
def create_basic
"Basic " << ["#{@config[:username]}:#{@config[:password]}"].pack("m").delete("\r\n")
end
##
# Finds the ReplyText and ReplyCode attributes in the response
#
# @param [Nokogiri::XML::NodeSet] rets <RETS> attributes found
#
# @return [String] RETS ReplyCode
# @return [String] RETS ReplyText
def get_rets_response(rets)
code, text = nil, nil
rets.attributes.each do |attr|
key = attr.first.downcase
if key == "replycode"
code = attr.last.value
elsif key == "replytext"
text = attr.last.value
end
end
return code, text
end
##
# Handles managing the relevant RETS-UA-Authorization headers
#
# @param [Hash] args
# @option args [String] :version RETS Version
# @option args [String, Optional] :session_id RETS Session ID
def setup_ua_authorization(args)
# Most RETS implementations don't care about RETS-Version for RETS-UA-Authorization, they don't require RETS-Version in general.
# Rapattoni require RETS-Version even without RETS-UA-Authorization, so will try and set the header when possible from the HTTP request rather than implying it.
# Interealty requires RETS-Version for RETS-UA-Authorization, so will fake it when we get an 20037 error
@headers["RETS-Version"] = args[:version] if args[:version]
if @headers["RETS-Version"] and @config[:useragent] and @config[:useragent][:password]
login = Digest::MD5.hexdigest("#{@config[:useragent][:name]}:#{@config[:useragent][:password]}")
@headers.merge!("RETS-UA-Authorization" => "Digest #{Digest::MD5.hexdigest("#{login}::#{args[:session_id]}:#{@headers["RETS-Version"]}")}")
end
end
##
# Sends a request to the RETS server.
#
# @param [Hash] args
# @option args [URI] :url URI to request data from
# @option args [Hash, Optional] :params Query string to include with the request
# @option args [Integer, Optional] :read_timeout How long to wait for the socket to return data before timing out
#
# @raise [RETS::APIError]
# @raise [RETS::HTTPError]
# @raise [RETS::Unauthorized]
def request(args, &block)
request_uri = args[:url].request_uri
data = ""
if args[:params]
args[:params].each do |k, v|
data << "#{k}=#{CGI.escape(v.to_s)}&" if v
end
end
headers = args[:headers]
if args[:disable_compression]
headers ||= {}
headers["Accept-Encoding"] = "identity"
end
# Digest will change every time due to how its setup
@request_count += 1
if @auth_mode == :digest
if headers
headers["Authorization"] = create_digest("POST", request_uri)
else
headers = {"Authorization" => create_digest("POST", request_uri)}
end
end
headers = headers ? @headers.merge(headers) : @headers
if !@config[:http][:proxy]
http = ::Net::HTTP.new(args[:url].host, args[:url].port)
else
http = ::Net::HTTP.new(args[:url].host, args[:url].port, @config[:http][:proxy][:address], @config[:http][:proxy][:port], @config[:http][:proxy][:username], @config[:http][:proxy][:password])
end
http.read_timeout = args[:read_timeout] if args[:read_timeout]
http.open_timeout = args[:open_timeout] if args[:open_timeout]
http.set_debug_output(@config[:debug_output]) if @config[:debug_output]
if args[:url].scheme == "https"
http.use_ssl = true
http.verify_mode = @config[:http][:verify_mode] || OpenSSL::SSL::VERIFY_NONE
http.ca_file = @config[:http][:ca_file] if @config[:http][:ca_file]
http.ca_path = @config[:http][:ca_path] if @config[:http][:ca_path]
end
http.start do
http.request_post(request_uri, data, headers) do |response|
# Pass along the cookies
# Some servers will continually call Set-Cookie with the same value for every single request
# to avoid authentication problems from cookies being stomped over (which is sad, nobody likes having their cookies crushed).
# We keep a hash of every cookie set and only update it if something changed
if response.header["set-cookie"]
cookies_changed = nil
response.header.get_fields("set-cookie").each do |cookie|
key, value = cookie.split(";").first.split("=")
key.strip!
# Sometimes we can get a nil value from raprets
unless value
cookies_changed = true if @cookie_list[key]
@cookie_list.delete(key)
next
end
value.strip!
# If it's a RETS-Session-ID, it needs to be shoved into the RETS-UA-Authorization field
# Save the RETS-Session-ID so it can be used with RETS-UA-Authorization
if key.downcase == "rets-session-id"
@rets_data[:session_id] = value
self.setup_ua_authorization(@rets_data) if @rets_data[:version]
end
cookies_changed = true if @cookie_list[key] != value
@cookie_list[key] = value
end
if cookies_changed
@headers.merge!("Cookie" => @cookie_list.map {|k, v| "#{k}=#{v}"}.join("; "))
end
end
# Rather than returning HTTP 401 when User-Agent authentication is needed, Retsiq returns HTTP 200
# with RETS error 20037. If we get a 20037, will let it pass through and handle it as if it was a HTTP 401.
# Retsiq apparently returns a 20041 now instead of a 20037 for the same use case.
# StratusRETS returns 20052 for an expired season
rets_code = nil
if response.code != "401" and ( response.code != "200" or args[:check_response] )
if response.body =~ /<RETS/i
rets_code, text = self.get_rets_response(Nokogiri::XML(response.body).xpath("//RETS").first)
unless rets_code == "20037" or rets_code == "20041" or rets_code == "20052" or rets_code == "0"
raise RETS::APIError.new("#{rets_code}: #{text}", rets_code, text)
end
elsif !args[:check_response]
raise RETS::HTTPError.new("#{response.code}: #{response.message} : #{request_uri}", response.code, response.message)
end
end
# Strictly speaking, we do not need to set a RETS-Version in most cases, if RETS-UA-Authorization is not used
# It makes more sense to be safe and set it. Innovia at least does not set this until authentication is successful
# which is why this check is also here for HTTP 200s and not just 401s
if response.code == "200" and !@rets_data[:version] and response.header["rets-version"] != ""
@rets_data[:version] = response.header["rets-version"]
end
# Digest can become stale requiring us to reload data
if @auth_mode == :digest and response.header["www-authenticate"] =~ /stale=true/i
save_digest(get_digest(response.header.get_fields("www-authenticate")))
args[:block] ||= block
return self.request(args)
elsif response.code == "401" or rets_code == "20037" or rets_code == "20041" or rets_code == "20052"
raise RETS::Unauthorized, "Cannot login, check credentials" if ( @auth_mode and @retried_request ) or ( @retried_request and rets_code == "20037" )
@retried_request = true
# We already have an auth mode, and the request wasn't retried.
# Meaning we know that we had a successful authentication but something happened so we should relogin.
if @auth_mode
@headers.delete("Cookie")
@cookie_list = {}
self.request(:url => login_uri)
return self.request(args.merge(:block => block))
end
# Find a valid way of authenticating to the server as some will support multiple methods
if response.header.get_fields("www-authenticate") and !response.header.get_fields("www-authenticate").empty?
digest = get_digest(response.header.get_fields("www-authenticate"))
if digest
save_digest(digest)
@auth_mode = :digest
else
@headers.merge!("Authorization" => create_basic)
@auth_mode = :basic
end
unless @auth_mode
raise RETS::HTTPError.new("Cannot authenticate, no known mode found", response.code)
end
end
# Check if we need to deal with User-Agent authorization
if response.header["rets-version"] and response.header["rets-version"] != ""
@rets_data[:version] = response.header["rets-version"]
# If we get a 20037 error, it could be due to not having a RETS-Version set
# Under Innovia, passing RETS/1.7 will cause some errors
# because they don't pass the RETS-Version header until a successful login which is a HTTP 200
# They also don't use RETS-UA-Authorization, and it's better to not imply the RETS-Version header
# unless necessary, so will only do it for 20037 errors now.
elsif !@rets_data[:version] and rets_code == "20037"
@rets_data[:version] = "RETS/1.7"
end
self.setup_ua_authorization(@rets_data)
args[:block] ||= block
return self.request(args)
# We just tried to auth and don't have access to the original block in yieldable form
elsif args[:block]
@retried_request = nil
args.delete(:block).call(response)
elsif block_given?
@retried_request = nil
yield response
end
end
end
end
end
end