diff --git a/lib/httpclient/auth.rb b/lib/httpclient/auth.rb index 1484a3dd..72268469 100644 --- a/lib/httpclient/auth.rb +++ b/lib/httpclient/auth.rb @@ -120,6 +120,10 @@ def filter_request(req) # Filter API implementation. Traps HTTP response and parses # 'WWW-Authenticate' header. + # + # This remembers the challenges for all authentication methods + # available to the client. On the subsequent retry of the request, + # filter_request will select the strongest method. def filter_response(req, res) command = nil if res.status == HTTP::Status::UNAUTHORIZED @@ -156,17 +160,19 @@ def filter_response(req, res) # SSPINegotiateAuth requires 'win32/sspi' module. class ProxyAuth < AuthFilterBase attr_reader :basic_auth + attr_reader :digest_auth attr_reader :negotiate_auth attr_reader :sspi_negotiate_auth # Creates new ProxyAuth. def initialize - @basic_auth = BasicAuth.new + @basic_auth = ProxyBasicAuth.new @negotiate_auth = NegotiateAuth.new @ntlm_auth = NegotiateAuth.new('NTLM') @sspi_negotiate_auth = SSPINegotiateAuth.new + @digest_auth = ProxyDigestAuth.new # sort authenticators by priority - @authenticator = [@negotiate_auth, @ntlm_auth, @sspi_negotiate_auth, @basic_auth] + @authenticator = [@negotiate_auth, @ntlm_auth, @sspi_negotiate_auth, @digest_auth, @basic_auth] end # Resets challenge state. See sub filters for more details. @@ -281,6 +287,25 @@ def challenge(uri, param_str = nil) end end + class ProxyBasicAuth < BasicAuth + + def set(uri, user, passwd) + @set = true + @cred = ["#{user}:#{passwd}"].pack('m').tr("\n", '') + end + + def get(req) + target_uri = req.header.request_uri + return nil unless @challengeable['challenged'] + @cred + end + + # Challenge handler: remember URL for response. + def challenge(uri, param_str = nil) + @challengeable['challenged'] = true + true + end + end # Authentication filter for handling DigestAuth negotiation. # Used in WWWAuth. @@ -352,9 +377,12 @@ def calc_cred(req, user, passwd, param) path = req.header.create_query_uri a_1 = "#{user}:#{param['realm']}:#{passwd}" a_2 = "#{method}:#{path}" + qop = param['qop'] nonce = param['nonce'] - cnonce = generate_cnonce() - @nonce_count += 1 + cnonce = nil + if qop || param['algorithm'] =~ /MD5-sess/ + cnonce = generate_cnonce() + end a_1_md5sum = Digest::MD5.hexdigest(a_1) if param['algorithm'] =~ /MD5-sess/ a_1_md5sum = Digest::MD5.hexdigest("#{a_1_md5sum}:#{nonce}:#{cnonce}") @@ -365,18 +393,25 @@ def calc_cred(req, user, passwd, param) message_digest = [] message_digest << a_1_md5sum message_digest << nonce - message_digest << ('%08x' % @nonce_count) - message_digest << cnonce - message_digest << param['qop'] + if qop + @nonce_count += 1 + message_digest << ('%08x' % @nonce_count) + message_digest << cnonce + message_digest << param['qop'] + end message_digest << Digest::MD5.hexdigest(a_2) header = [] header << "username=\"#{user}\"" header << "realm=\"#{param['realm']}\"" header << "nonce=\"#{nonce}\"" header << "uri=\"#{path}\"" - header << "cnonce=\"#{cnonce}\"" - header << "nc=#{'%08x' % @nonce_count}" - header << "qop=#{param['qop']}" + if cnonce + header << "cnonce=\"#{cnonce}\"" + end + if qop + header << "nc=#{'%08x' % @nonce_count}" + header << "qop=#{param['qop']}" + end header << "response=\"#{Digest::MD5.hexdigest(message_digest.join(":"))}\"" header << "algorithm=#{algorithm}" header << "opaque=\"#{param['opaque']}\"" if param.key?('opaque') @@ -404,6 +439,39 @@ def parse_challenge_param(param_str) end + # Authentication filter for handling DigestAuth negotiation. + # Ignores uri argument. Used in ProxyAuth. + class ProxyDigestAuth < DigestAuth + + # overrides DigestAuth#set. sets default user name and password. uri is not used. + def set(uri, user, passwd) + @set = true + @auth = [user, passwd] + end + + # overrides DigestAuth#get. Uses default user name and password + # regardless of target uri if the proxy has required authentication + # before + def get(req) + target_uri = req.header.request_uri + param = @challenge + return nil unless param + user, passwd = @auth + return nil unless user + calc_cred(req, user, passwd, param) + end + + def reset_challenge + @challenge = nil + end + + def challenge(uri, param_str) + @challenge = parse_challenge_param(param_str) + true + end + + end + # Authentication filter for handling Negotiate/NTLM negotiation. # Used in WWWAuth and ProxyAuth. # diff --git a/test/test_auth.rb b/test/test_auth.rb index 2e4d4d38..f53dad53 100644 --- a/test/test_auth.rb +++ b/test/test_auth.rb @@ -1,5 +1,5 @@ require File.expand_path('helper', File.dirname(__FILE__)) - +require 'digest/md5' class TestAuth < Test::Unit::TestCase include Helper @@ -56,6 +56,23 @@ def setup_server :UserDB => htdigest_userdb ) @server_thread = start_server_thread(@server) + + @proxy_digest_auth = WEBrick::HTTPAuth::ProxyDigestAuth.new( + :Logger => @proxylogger, + :Algorithm => 'MD5', + :Realm => 'auth', + :UserDB => htdigest_userdb + ) + + @proxyserver = WEBrick::HTTPProxyServer.new( + :ProxyAuthProc => @proxy_digest_auth.method(:authenticate).to_proc, + :BindAddress => "localhost", + :Logger => @proxylogger, + :Port => 0, + :AccessLog => [] + ) + @proxyport = @proxyserver.config[:Port] + @proxyserver_thread = start_server_thread(@proxyserver) end def do_basic_auth(req, res) @@ -105,12 +122,32 @@ def test_BASIC_auth end end + def test_basic_auth_reuses_credentials + c = HTTPClient.new + c.set_auth("http://localhost:#{serverport}/", 'admin', 'admin') + assert_equal('basic_auth OK', c.get_content("http://localhost:#{serverport}/basic_auth/")) + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + c.debug_dev = str = '' + c.get_content("http://localhost:#{serverport}/basic_auth/sub/dir/") + assert_match /Authorization: Basic YWRtaW46YWRtaW4=/, str + end + def test_digest_auth c = HTTPClient.new c.set_auth("http://localhost:#{serverport}/", 'admin', 'admin') assert_equal('digest_auth OK', c.get_content("http://localhost:#{serverport}/digest_auth")) end + def test_digest_auth_reuses_credentials + c = HTTPClient.new + c.set_auth("http://localhost:#{serverport}/", 'admin', 'admin') + assert_equal('digest_auth OK', c.get_content("http://localhost:#{serverport}/digest_auth/")) + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + c.debug_dev = str = '' + c.get_content("http://localhost:#{serverport}/digest_auth/sub/dir/") + assert_match /Authorization: Digest/, str + end + def test_digest_auth_with_block c = HTTPClient.new c.set_auth("http://localhost:#{serverport}/", 'admin', 'admin') @@ -147,6 +184,16 @@ def test_digest_auth_with_querystring assert_equal('digest_auth OKbar=baz', c.get_content("http://localhost:#{serverport}/digest_auth/foo?bar=baz")) end + def test_perfer_digest + c = HTTPClient.new + c.set_auth('http://example.com/', 'admin', 'admin') + c.test_loopback_http_response << "HTTP/1.0 401 Unauthorized\nWWW-Authenticate: Basic realm=\"foo\"\nWWW-Authenticate: Digest realm=\"foo\", nonce=\"nonce\", stale=false\nContent-Length: 2\n\nNG" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + c.debug_dev = str = '' + c.get_content('http://example.com/') + assert_match(/^Authorization: Digest/, str) + end + def test_digest_sess_auth c = HTTPClient.new c.set_auth("http://localhost:#{serverport}/", 'admin', 'admin') @@ -163,6 +210,81 @@ def test_proxy_auth assert_match(/Proxy-Authorization: Basic YWRtaW46YWRtaW4=/, str) end + def test_proxy_auth_reuses_credentials + c = HTTPClient.new + c.set_proxy_auth('admin', 'admin') + c.test_loopback_http_response << "HTTP/1.0 407 Unauthorized\nProxy-Authenticate: Basic realm=\"foo\"\nContent-Length: 2\n\nNG" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + c.get_content('http://www1.example.com/') + c.debug_dev = str = '' + c.get_content('http://www2.example.com/') + assert_match(/Proxy-Authorization: Basic YWRtaW46YWRtaW4=/, str) + end + + def test_digest_proxy_auth_loop + c = HTTPClient.new + c.set_proxy_auth('admin', 'admin') + c.test_loopback_http_response << "HTTP/1.0 407 Unauthorized\nProxy-Authenticate: Digest realm=\"foo\", nonce=\"nonce\", stale=false\nContent-Length: 2\n\nNG" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + md5 = Digest::MD5.new + ha1 = md5.hexdigest("admin:foo:admin") + ha2 = md5.hexdigest("GET:/") + response = md5.hexdigest("#{ha1}:nonce:#{ha2}") + c.debug_dev = str = '' + c.get_content('http://example.com/') + assert_match(/Proxy-Authorization: Digest/, str) + assert_match(%r"response=\"#{response}\"", str) + end + + def test_digest_proxy_auth + c=HTTPClient.new("http://localhost:#{proxyport}/") + c.set_proxy_auth('admin', 'admin') + c.set_auth("http://127.0.0.1:#{serverport}/", 'admin', 'admin') + assert_equal('basic_auth OK', c.get_content("http://127.0.0.1:#{serverport}/basic_auth")) + end + + def test_digest_proxy_invalid_auth + c=HTTPClient.new("http://localhost:#{proxyport}/") + c.set_proxy_auth('admin', 'wrong') + c.set_auth("http://127.0.0.1:#{serverport}/", 'admin', 'admin') + assert_raises(HTTPClient::BadResponseError) do + c.get_content("http://127.0.0.1:#{serverport}/basic_auth") + end + end + + def test_prefer_digest_to_basic_proxy_auth + c = HTTPClient.new + c.set_proxy_auth('admin', 'admin') + c.test_loopback_http_response << "HTTP/1.0 407 Unauthorized\nProxy-Authenticate: Digest realm=\"foo\", nonce=\"nonce\", stale=false\nProxy-Authenticate: Basic realm=\"bar\"\nContent-Length: 2\n\nNG" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + md5 = Digest::MD5.new + ha1 = md5.hexdigest("admin:foo:admin") + ha2 = md5.hexdigest("GET:/") + response = md5.hexdigest("#{ha1}:nonce:#{ha2}") + c.debug_dev = str = '' + c.get_content('http://example.com/') + assert_match(/Proxy-Authorization: Digest/, str) + assert_match(%r"response=\"#{response}\"", str) + end + + def test_digest_proxy_auth_reuses_credentials + c = HTTPClient.new + c.set_proxy_auth('admin', 'admin') + c.test_loopback_http_response << "HTTP/1.0 407 Unauthorized\nProxy-Authenticate: Digest realm=\"foo\", nonce=\"nonce\", stale=false\nContent-Length: 2\n\nNG" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + c.test_loopback_http_response << "HTTP/1.0 200 OK\nContent-Length: 2\n\nOK" + md5 = Digest::MD5.new + ha1 = md5.hexdigest("admin:foo:admin") + ha2 = md5.hexdigest("GET:/") + response = md5.hexdigest("#{ha1}:nonce:#{ha2}") + c.get_content('http://www1.example.com/') + c.debug_dev = str = '' + c.get_content('http://www2.example.com/') + assert_match(/Proxy-Authorization: Digest/, str) + assert_match(%r"response=\"#{response}\"", str) + end + def test_oauth c = HTTPClient.new config = HTTPClient::OAuth::Config.new(