diff --git a/lib/http-access2.rb b/lib/http-access2.rb index 08eb7271..92e9b9c7 100644 --- a/lib/http-access2.rb +++ b/lib/http-access2.rb @@ -35,6 +35,13 @@ module HTTPAccess2 false end + SSPIEnabled = begin + require 'win32/sspi' + true + rescue LoadError + false + end + DEBUG_SSL = true @@ -54,6 +61,34 @@ def uri_part_of(uri, part) uri.path.upcase.index(part.path.upcase) == 0) end module_function :uri_part_of + + def uri_dirname(uri) + uri = uri.clone + uri.path = uri.path.sub(/\/[^\/]*\z/, '/') + uri + end + module_function :uri_dirname + + def hash_find_value(hash) + hash.each do |k, v| + return v if yield(k, v) + end + nil + end + module_function :hash_find_value + + def parse_challenge_param(param_str) + param = {} + param_str.scan(/\s*([^\,]+(?:\\.[^\,]*)*)/).each do |str| + key, value = str[0].scan(/\A([^=]+)=(.*)\z/)[0] + if /\A"(.*)"\z/ =~ value + value = $1.gsub(/\\(.)/, '\1') + end + param[key] = value + end + param + end + module_function :parse_challenge_param end @@ -94,8 +129,7 @@ class Client attr_reader :test_loopback_response attr_reader :request_filter attr_reader :proxy_auth - attr_reader :basic_auth - attr_reader :digest_auth + attr_reader :www_auth class << self %w(get_content head get post put delete options trace).each do |name| @@ -110,7 +144,6 @@ def #{name}(*arg) class RetryableResponse < StandardError # :nodoc: end - # SYNOPSIS # Client.new(proxy = nil, agent_name = nil, from = nil) # @@ -128,10 +161,9 @@ def initialize(proxy = nil, agent_name = nil, from = nil) @no_proxy = nil @agent_name = agent_name @from = from - @proxy_auth = ProxyAuth.new - @basic_auth = BasicAuth.new(self) - @digest_auth = DigestAuth.new(self) - @request_filter = [@proxy_auth, @basic_auth, @digest_auth] + @www_auth = WWWAuth.new(self) + @proxy_auth = ProxyAuth.new(self) + @request_filter = [@proxy_auth, @www_auth] @debug_dev = nil @redirect_uri_callback = method(:default_redirect_uri_callback) @test_loopback_response = [] @@ -196,16 +228,16 @@ def proxy def proxy=(proxy) if proxy.nil? @proxy = nil - @proxy_auth.clear + @proxy_auth.reset_challenge else @proxy = urify(proxy) if @proxy.scheme == nil or @proxy.scheme.downcase != 'http' or @proxy.host == nil or @proxy.port == nil raise ArgumentError.new("unsupported proxy `#{proxy}'") end - @proxy_auth.clear + @proxy_auth.reset_challenge if @proxy.user || @proxy.password - @proxy_auth.set(@proxy.user, @proxy.password) + @proxy_auth.set_auth(@proxy.user, @proxy.password) end end reset_all @@ -229,11 +261,16 @@ def socket_sync=(socket_sync) def set_auth(uri, user, passwd) uri = urify(uri) - @basic_auth.set(uri, user, passwd) - @digest_auth.set(uri, user, passwd) + @www_auth.set_auth(uri, user, passwd) end + # for backward compatibility alias set_basic_auth set_auth + def set_proxy_auth(user, passwd) + uri = urify(uri) + @proxy_auth.set_auth(user, passwd) + end + def set_cookie_store(filename) if @cookie_manager.cookies_file raise RuntimeError.new("overriding cookie file location") @@ -267,13 +304,13 @@ def redirect_uri_callback=(redirect_uri_callback) # Get a_sring of message-body of response. # def get_content(uri, query = nil, extheader = {}, &block) - retry_connect(uri, query) { |uri, query| + follow_redirect(uri, query) { |uri, query| get(uri, query, extheader, &block) }.content end def post_content(uri, body = nil, extheader = {}, &block) - retry_connect(uri, nil) { |uri, query| + follow_redirect(uri, nil) { |uri, query| post(uri, body, extheader, &block) }.content end @@ -328,17 +365,21 @@ def trace(uri, query = nil, body = nil, extheader = {}, &block) def request(method, uri, query = nil, body = nil, extheader = {}, &block) uri = urify(uri) conn = Connection.new - begin - prepare_request(method, uri, query, body, extheader) do |req, proxy| - do_get_block(req, proxy, conn, &block) - end - rescue Client::RetryableResponse - res = conn.pop - prepare_request(method, uri, query, body, extheader) do |req, proxy| - do_get_block(req, proxy, conn, &block) + res = nil + retry_count = 5 + while retry_count > 0 + begin + prepare_request(method, uri, query, body, extheader) do |req, proxy| + do_get_block(req, proxy, conn, &block) + end + res = conn.pop + break + rescue Client::RetryableResponse + res = conn.pop + retry_count -= 1 end end - conn.pop + res end # Async interface. @@ -402,7 +443,7 @@ def reset_all private - def retry_connect(uri, query = nil) + def follow_redirect(uri, query = nil) retry_number = 0 while retry_number < 10 res = yield(uri, query) @@ -824,66 +865,31 @@ def load_cacerts end -# HTTPAccess2::AuthBase -- Authentication repository base. +# HTTPAccess2::BasicAuth -- BasicAuth repository. # -class AuthBase # :nodoc: - def initialize(client) - @client = client +class BasicAuth # :nodoc: + def initialize + @cred = nil + @auth = {} @challengeable = {} end - def reset_challenge - @challengeable.clear - end - -private - - def reset_all - @client.reset_all - end - - def parse_authentication_header(res, tag) - challenge = res.header[tag] - unless challenge - raise RuntimeError.new("no #{tag} header exists: #{res}") - end - if challenge.size != 1 - raise RuntimeError.new("multiple #{tag} header exists: #{res}") - end - parse_challenge_header(challenge.first) - end - - def parse_challenge_header(challenge) - scheme, param_str = challenge.scan(/\A(\S+)\s+(.*)\z/)[0] - if scheme.nil? - raise RuntimeError.new("unsupported challenge: #{challenge}") - end - param = {} - param_str.scan(/\s*([^\,]+(?:\\.[^\,]*)*)/).each do |str| - key, value = str[0].scan(/\A([^=]+)=(.*)\z/)[0] - if /\A"(.*)"\z/ =~ value - value = $1.gsub(/\\(.)/, '\1') - end - param[key] = value - end - return scheme, param + def scheme + "Basic" end -end - -# HTTPAccess2::BasicAuth -- BasicAuth repository. -# -class BasicAuth < AuthBase # :nodoc: - def initialize(client) - super(client) - @auth = {} + def reset_challenge + @challengeable.clear end + # uri == nil for generic purpose def set(uri, user, passwd) - uri = uri.clone - uri.path = uri.path.sub(/\/[^\/]*$/, '/') - @auth[uri] = ["#{user}:#{passwd}"].pack('m').tr("\n", '') - reset_all + if uri.nil? + @cred = ["#{user}:#{passwd}"].pack('m').tr("\n", '') + else + uri = Util.uri_dirname(uri) + @auth[uri] = ["#{user}:#{passwd}"].pack('m').tr("\n", '') + end end # send cred only when a given uri is; @@ -894,34 +900,13 @@ def get(req) return nil unless @challengeable.find { |uri, ok| Util.uri_part_of(target_uri, uri) and ok } - found = @auth.find { |uri, cred| + return @cred if @cred + Util.hash_find_value(@auth) { |uri, cred| Util.uri_part_of(target_uri, uri) } - return found.last if found end - def filter_request(req) - if cred = get(req) - req.header.set('Authorization', "Basic " + cred) - end - end - - def filter_response(req, res) - if res.status == HTTP::Status::UNAUTHORIZED - if challenge = parse_authentication_header(res, 'www-authenticate') - scheme, param = challenge - if scheme == "Basic" - challengeable = challenge(req.header.request_uri, param) - return :retry if challengeable - end - end - end - nil - end - -private - - def challenge(uri, param) + def challenge(uri, param_str) @challengeable[uri] = true true end @@ -930,19 +915,24 @@ def challenge(uri, param) # HTTPAccess2::DigestAuth # -class DigestAuth < AuthBase # :nodoc: - def initialize(client) - super(client) +class DigestAuth # :nodoc: + def initialize @auth = {} @challenge = {} @nonce_count = 0 end + def scheme + "Digest" + end + + def reset_challenge + @challenge.clear + end + def set(uri, user, passwd) - uri = uri.clone - uri.path = uri.path.sub(/\/[^\/]*$/, '/') + uri = Util.uri_dirname(uri) @auth[uri] = [user, passwd] - reset_all end # send cred only when a given uri is; @@ -950,49 +940,29 @@ def set(uri, user, passwd) # - child page of defined credential def get(req) target_uri = req.header.request_uri - found = @challenge.find { |uri, param| + param = Util.hash_find_value(@challenge) { |uri, param| Util.uri_part_of(target_uri, uri) } - return nil unless found - param = found.last - found = @auth.find { |uri, auth_data| + return nil unless param + user, passwd = Util.hash_find_value(@auth) { |uri, auth_data| Util.uri_part_of(target_uri, uri) } - return nil unless found - user, passwd = found.last + return nil unless user uri = req.header.request_uri calc_cred(req.header.request_method, uri, user, passwd, param) end - def filter_request(req) - if cred = get(req) - req.header.set('Authorization', "Digest " + cred) - end - end - - def filter_response(req, res) - if res.status == HTTP::Status::UNAUTHORIZED - if challenge = parse_authentication_header(res, 'www-authenticate') - scheme, param = challenge - if scheme == "Digest" - challengeable = challenge(req.header.request_uri, param) - return :retry if challengeable - end - end - end - nil + def challenge(uri, param_str) + @challenge[uri] = Util.parse_challenge_param(param_str) + true end private - def challenge(uri, param) - @challenge[uri] = param - true - end - # 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) a_1 = "#{user}:#{param['realm']}:#{passwd}" a_2 = "#{method}:#{uri.path}" @@ -1013,7 +983,6 @@ def calc_cred(method, uri, user, passwd, param) header << "nc=#{'%08x' % @nonce_count}" header << "qop=\"#{param['qop']}\"" header << "response=\"#{Digest::MD5.hexdigest(message_digest.join(":"))}\"" - #header << "algorithm=\"#{param['algorithm']}\"" header << "algorithm=\"MD5\"" header << "opaque=\"#{param['opaque']}\"" if param.key?('opaque') header.join(", ") @@ -1021,61 +990,184 @@ def calc_cred(method, uri, user, passwd, param) end -# HTTPAccess2::ProxyAuth -- ProxyAuth repository. +# HTTPAccess2::NegotiateAuth # -class ProxyAuth # :nodoc: +class NegotiateAuth # :nodoc: def initialize - @cred = nil - @challengeable = {} - end - - def clear - @cred = nil + @challenge = {} end - def set(user, passwd) - @cred = ["#{user}:#{passwd}"].pack('m').strip + def scheme + "Negotiate" end def reset_challenge - @challengeable.clear + @challenge.clear end - # send cred only when a given uri is; - # - child page of challengeable(got Proxy-Authenticate before) uri def get(req) + return nil unless SSPIEnabled target_uri = req.header.request_uri - return nil unless @cred - return nil unless @challengeable.find { |uri, ok| - Util.uri_part_of(target_uri, uri) and ok + param = Util.hash_find_value(@challenge) { |uri, param| + Util.uri_part_of(target_uri, uri) } - @cred + return nil unless param + state = param[:state] + authenticator = param[:authenticator] + authphrase = param[:authphrase] + case state + when :init + authenticator = param[:authenticator] = Win32::SSPI::NegotiateAuth.new + return authenticator.get_initial_token + when :response + return authenticator.complete_authentication(authphrase) + end + nil + end + + def challenge(uri, param_str) + return false unless SSPIEnabled + if param_str.nil? or @challenge[uri].nil? + c = @challenge[uri] = {} + c[:state] = :init + c[:authenticator] = nil + c[:authphrase] = "" + else + c = @challenge[uri] + c[:state] = :response + c[:authphrase] = param_str + end + true + end +end + + +class AuthFilterBase # :nodoc: +private + + def parse_authentication_header(res, tag) + challenge = res.header[tag] + unless challenge + raise RuntimeError.new("no #{tag} header exists: #{res}") + end + challenge.collect { |c| parse_challenge_header(c) } + end + + def parse_challenge_header(challenge) + scheme, param_str = challenge.scan(/\A(\S+)(?:\s+(.*))?\z/)[0] + if scheme.nil? + raise RuntimeError.new("unsupported challenge: #{challenge}") + end + return scheme, param_str + end +end + + +class WWWAuth < AuthFilterBase # :nodoc: + attr_reader :basic_auth + attr_reader :digest_auth + attr_reader :negotiate_auth + + def initialize(client) + @client = client + @basic_auth = BasicAuth.new + @digest_auth = DigestAuth.new + @negotiate_auth = NegotiateAuth.new + # sort authenticators by priority + @authenticator = [@negotiate_auth, @digest_auth, @basic_auth] + end + + def reset_challenge + @authenticator.each do |auth| + auth.reset_challenge + end + end + + def set_auth(uri, user, passwd) + @basic_auth.set(uri, user, passwd) + @digest_auth.set(uri, user, passwd) + @client.reset_all end def filter_request(req) - if cred = get(req) - req.header.set('Proxy-Authorization', "Basic " + cred) + @authenticator.each do |auth| + if cred = auth.get(req) + req.header.set('Authorization', auth.scheme + " " + cred) + return + end end end def filter_response(req, res) - if res.status == HTTP::Status::PROXY_AUTHENTICATE_REQUIRED - if challenge = parse_authentication_header(res, 'proxy-authenticate') - scheme, param = challenge - if scheme == "Basic" - challengeable = challenge(req.header.request_uri, param) - return :retry if challengeable + command = nil + uri = req.header.request_uri + if res.status == HTTP::Status::UNAUTHORIZED + if challenge = parse_authentication_header(res, 'www-authenticate') + challenge.each do |scheme, param_str| + @authenticator.each do |auth| + if scheme == auth.scheme + challengeable = auth.challenge(uri, param_str) + command = :retry if challengeable + end + end end + # ignore unknown authentication scheme end end - nil + command end +end -private - def challenge(uri, param) - @challengeable[uri] = true - true +class ProxyAuth < AuthFilterBase # :nodoc: + attr_reader :basic_auth + attr_reader :negotiate_auth + + def initialize(client) + @client = client + @basic_auth = BasicAuth.new + @negotiate_auth = NegotiateAuth.new + # sort authenticators by priority + @authenticator = [@negotiate_auth, @basic_auth] + end + + def reset_challenge + @authenticator.each do |auth| + auth.reset_challenge + end + end + + def set_auth(user, passwd) + @basic_auth.set(nil, user, passwd) + @client.reset_all + end + + def filter_request(req) + @authenticator.each do |auth| + if cred = auth.get(req) + req.header.set('Proxy-Authorization', auth.scheme + " " + cred) + return + end + end + end + + def filter_response(req, res) + command = nil + uri = req.header.request_uri + if res.status == HTTP::Status::PROXY_AUTHENTICATE_REQUIRED + if challenge = parse_authentication_header(res, 'proxy-authenticate') + challenge.each do |scheme, param_str| + @authenticator.each do |auth| + if scheme == auth.scheme + challengeable = auth.challenge(uri, param_str) + command = :retry if challengeable + end + end + end + # ignore unknown authentication scheme + end + end + command end end diff --git a/test/htdigest b/test/htdigest new file mode 100644 index 00000000..0a125d9c --- /dev/null +++ b/test/htdigest @@ -0,0 +1 @@ +admin:auth:4302fe65caa32f27721949149ccd3083 diff --git a/test/htpasswd b/test/htpasswd new file mode 100644 index 00000000..70df50c9 --- /dev/null +++ b/test/htpasswd @@ -0,0 +1,2 @@ +admin:Qg266hq/YYKe2 +guest:gbPc4vPCH.h12 diff --git a/test/test_auth.rb b/test/test_auth.rb new file mode 100644 index 00000000..ee983d0c --- /dev/null +++ b/test/test_auth.rb @@ -0,0 +1,99 @@ +require 'test/unit' +require 'webrick' +require 'logger' +require 'http-access2' + + +class TestAuth < Test::Unit::TestCase + Port = 17171 + + def setup + @logger = Logger.new(STDERR) + @logger.level = Logger::Severity::ERROR + @url = "http://localhost:#{Port}/" + @server = nil + @server_thread = nil + setup_server + end + + def teardown + teardown_server + end + + def setup_server + @server = WEBrick::HTTPServer.new( + :BindAddress => "0.0.0.0", + :Logger => @logger, + :Port => Port, + :AccessLog => [], + :DocumentRoot => File.dirname(File.expand_path(__FILE__)) + ) + @server.mount( + '/basic_auth', + WEBrick::HTTPServlet::ProcHandler.new(method(:do_basic_auth).to_proc) + ) + @server.mount( + '/digest_auth', + WEBrick::HTTPServlet::ProcHandler.new(method(:do_digest_auth).to_proc) + ) + htpasswd = File.join(File.dirname(__FILE__), 'htpasswd') + htpasswd_userdb = WEBrick::HTTPAuth::Htpasswd.new(htpasswd) + htdigest = File.join(File.dirname(__FILE__), 'htdigest') + htdigest_userdb = WEBrick::HTTPAuth::Htdigest.new(htdigest) + @basic_auth = WEBrick::HTTPAuth::BasicAuth.new( + :Realm => 'auth', + :UserDB => htpasswd_userdb + ) + @digest_auth = WEBrick::HTTPAuth::DigestAuth.new( + :Algorithm => 'MD5', + :Realm => 'auth', + :UserDB => htdigest_userdb + ) + @server_thread = start_server_thread(@server) + end + + def start_server_thread(server) + t = Thread.new { + Thread.current.abort_on_exception = true + server.start + } + while server.status != :Running + sleep 0.1 + unless t.alive? + t.join + raise + end + end + t + end + + def teardown_server + @server.shutdown + @server_thread.kill + @server_thread.join + end + + def do_basic_auth(req, res) + @basic_auth.authenticate(req, res) + res['content-type'] = 'text/plain' + res.body = 'basic_auth OK' + end + + def do_digest_auth(req, res) + @digest_auth.authenticate(req, res) + res['content-type'] = 'text/plain' + res.body = 'digest_auth OK' + end + + def test_basic_auth + c = HTTPAccess2::Client.new + c.set_auth("http://localhost:#{Port}/", 'admin', 'admin') + assert_equal('basic_auth OK', c.get_content("http://localhost:#{Port}/basic_auth")) + end + + def test_digest_auth + c = HTTPAccess2::Client.new + c.set_auth("http://localhost:#{Port}/", 'admin', 'admin') + assert_equal('digest_auth OK', c.get_content("http://localhost:#{Port}/digest_auth")) + end +end diff --git a/test/test_http-access2.rb b/test/test_http-access2.rb index 1f8a03b1..b09c7e2e 100644 --- a/test/test_http-access2.rb +++ b/test/test_http-access2.rb @@ -203,39 +203,6 @@ def test_no_proxy end end - def test_basic_auth - str = "" - @client.debug_dev = str - @client.set_basic_auth("http://notlocalhost/foo/", "foo", "bar") - @client.head(@url) - lines = str.split(/(?:\r?\n)+/) - assert_equal("= Request", lines[0]) - assert_match(/^Date/, lines[4]) - # - @client.set_basic_auth(@url + "bar/", "foo", "bar") - str = "" - @client.debug_dev = str - @client.head(@url + "foo/") - lines = str.split(/(?:\r?\n)+/) - assert_equal("= Request", lines[0]) - assert_match(/^Date/, lines[4]) - # - @client.set_basic_auth(@url + "foo/bar", "foo", "bar") - str = "" - @client.debug_dev = str - @client.head(@url + "foo/") - lines = str.split(/(?:\r?\n)+/) - assert_equal("= Request", lines[0]) - assert_equal("Authorization: Basic Zm9vOmJhcg==", lines[4]) - # - str = "" - @client.debug_dev = str - @client.head(@url + "foo/baz/baz.txt") - lines = str.split(/(?:\r?\n)+/) - assert_equal("= Request", lines[0]) - assert_equal("Authorization: Basic Zm9vOmJhcg==", lines[4]) - end - def test_get_content assert_equal('hello', @client.get_content(@url + 'hello')) assert_equal('hello', @client.get_content(@url + 'redirect1'))