From 68faf1c4a1e589db4dac9897febbb7d3848f3f38 Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Fri, 15 Dec 2017 18:15:04 +0200 Subject: [PATCH] added authentication plugin with tests (basic is working, digest is not) --- lib/httpx/plugins/authentication.rb | 182 ++++++++++++++++++ test/http1_test.rb | 4 +- test/support/assertion_helpers.rb | 6 +- .../requests/plugins/authentication.rb | 68 +++++++ 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 lib/httpx/plugins/authentication.rb create mode 100644 test/support/requests/plugins/authentication.rb diff --git a/lib/httpx/plugins/authentication.rb b/lib/httpx/plugins/authentication.rb new file mode 100644 index 00000000..a0d0b480 --- /dev/null +++ b/lib/httpx/plugins/authentication.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module HTTPX + module Plugins + module Authentication + DigestError = Class.new(Error) + + def self.load_dependencies(*) + require "base64" + require "securerandom" + require "digest" + end + + module InstanceMethods + def authentication(token) + headers("authorization" => token) + end + + def basic_authentication(user, password) + authentication("Basic #{Base64.strict_encode64("#{user}:#{password}")}") + end + alias :basic_auth :basic_authentication + + def digest_authentication(user, password) + @_digest_auth_user = user + @_digest_auth_pass = password + @_digest = Digest.new + self + end + alias :digest_auth :digest_authentication + + def request(*args, **options) + return super unless @_digest + begin + #keep_open = @keep_open + #@keep_open = true + + requests = __build_reqs(*args, **options) + responses = __send_reqs(*requests) + + failed_requests = [] + failed_responses_ids = responses.each_with_index.map do |response, index| + next unless response.status == 401 + request = requests[index] + + token = @_digest.generate_header(@_digest_auth_user, + @_digest_auth_pass, + request, + response) + + request.headers["authorization"] = "Digest #{token}" + request.transition(:idle) + + failed_requests << request + + index + end.compact + + return responses if failed_requests.empty? + + repeated_responses = __send_reqs(*failed_requests) + repeated_responses.each_with_index do |rep, index| + responses[index] = rep + end + return responses.first if responses.size == 1 + responses + ensure + #@keep_open = keep_open + end + end + end + + class Digest + def initialize + @nonce = 0 + end + + def generate_header(user, password, request, response, iis = false) + method = request.verb.to_s.upcase + www = response.headers["www-authenticate"] + + # TODO: assert if auth-type is Digest + auth_info = www[/^(\w+) (.*)/, 2] + + + params = Hash[ auth_info.scan(/(\w+)="(.*?)"/) ] + + nonce = params["nonce"] + nc = next_nonce + + # verify qop + qop = params["qop"] + + if params["algorithm"] =~ /(.*?)(-sess)?$/ + algorithm = case $1 + when "MD5" then ::Digest::MD5 + when "SHA1" then ::Digest::SHA1 + when "SHA2" then ::Digest::SHA2 + when "SHA256" then ::Digest::SHA256 + when "SHA384" then ::Digest::SHA384 + when "SHA512" then ::Digest::SHA512 + when "RMD160" then ::Digest::RMD160 + else raise DigestError, "unknown algorithm \"#{$1}\"" + end + sess = $2 + else + algorithm = ::Digest::MD5 + end + + if qop or sess + cnonce = make_cnonce + nc = "%08x" % nc + end + + a1 = if sess then + [ algorithm.hexdigest("#{user}:#{params["realm"]}:#{password}"), + nonce, + cnonce, + ].join ":" + else + "#{user}:#{params["realm"]}:#{password}" + end + + ha1 = algorithm.hexdigest(a1) + ha2 = algorithm.hexdigest("#{method}:#{request.path}") + + request_digest = [ha1, nonce] + request_digest.push(nc, cnonce, qop) if qop + request_digest << ha2 + request_digest = request_digest.join(":") + + header = [ + "username=\"#{user}\"", + "response=\"#{algorithm.hexdigest(request_digest)}\"", + "uri=\"#{request.path}\"", + "nonce=\"#{nonce}\"" + ] + header << "realm=\"#{params["realm"]}\"" if params.key?("realm") + header << "opaque=\"#{params["opaque"]}\"" if params.key?("opaque") + header << "algorithm=#{params["algorithm"]}" if params.key?("algorithm") + header << "cnonce=#{cnonce}" if cnonce + header << "nc=#{nc}" + header << "qop=#{qop}" if qop + # + # if qop.nil? then + # elsif iis then + # "qop=\"#{qop}\"" + # else + # "qop=#{qop}" + # end, + # if qop then + # [ + # "nc=#{"%08x" % nonce}", + # "cnonce=\"#{cnonce}\"", + # ] + # end, + # if params.key?("opaque") then + # "opaque=\"#{params["opaque"]}\"" + # end + # ].compact + + header.join ", " + end + + private + + def make_cnonce + ::Digest::MD5.hexdigest [ + Time.now.to_i, + Process.pid, + SecureRandom.random_number(2**32), + ].join ":" + end + + def next_nonce + @nonce += 1 + end + end + end + register_plugin :authentication, Authentication + end +end diff --git a/test/http1_test.rb b/test/http1_test.rb index 59b74980..470f0ff1 100644 --- a/test/http1_test.rb +++ b/test/http1_test.rb @@ -12,7 +12,9 @@ class HTTP1Test < HTTPTest include Headers include ResponseBody include IO - + + include Plugins::Authentication + private def origin diff --git a/test/support/assertion_helpers.rb b/test/support/assertion_helpers.rb index 44d75cfc..848b7338 100644 --- a/test/support/assertion_helpers.rb +++ b/test/support/assertion_helpers.rb @@ -12,7 +12,11 @@ module ResponseHelpers def verify_#{meth}(#{meth}s, key, expect) assert #{meth}s.key?(key), "#{meth}s don't contain the given key (" + key + ")" value = #{meth}s[key] - assert value.start_with?(expect), "#{meth} assertion failed: " + key + "=" + value + " (expected: " + expect + ")" + if value.respond_to?(:start_with?) + assert value.start_with?(expect), "#{meth} assertion failed: " + key + "=" + value + " (expected: " + expect + ")" + else + assert value == expect, "#{meth} assertion failed: " + key + "=" + value.to_s + " (expected: " + expect.to_s + ")" + end end DEFINE end diff --git a/test/support/requests/plugins/authentication.rb b/test/support/requests/plugins/authentication.rb new file mode 100644 index 00000000..3d41581f --- /dev/null +++ b/test/support/requests/plugins/authentication.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "base64" + +module Requests + module Plugins + module Authentication + + def test_plugin_authentication_no_auth + + end + + def test_plugin_authentication_auth + + end + + def test_plugin_authentication_no_basic_auth + response = HTTPX.get(basic_auth_uri) + verify_status(response.status, 401) + verify_header(response.headers, "www-authenticate", "Basic realm=\"Fake Realm\"") + end + + def test_plugin_authentication_basic_auth + client = HTTPX.plugin(:authentication) + response = client.basic_authentication(user, pass).get(basic_auth_uri) + verify_status(response.status, 200) + body = json_body(response) + verify_header(body, "authenticated", true) + verify_header(body, "user", user) + + invalid_response = client.basic_authentication(user, "fake").get(basic_auth_uri) + verify_status(invalid_response.status, 401) + end + + def test_plugin_authentication_digest_auth + client = HTTPX.plugin(:authentication) + response = client.digest_authentication(user, pass).get(digest_auth_uri) + verify_status(response.status, 200) + body = json_body(response) + verify_header(body, "authenticated", true) + verify_header(body, "user", user) + end + + private + + def basic_auth_uri + build_uri("/basic-auth/#{user}/#{pass}") + end + + def digest_auth_uri(qop="auth") + build_uri("/digest-auth/#{qop}/#{user}/#{pass}") + end + + def user + "user" + end + + def pass + "pass" + end + + def basic_auth_token + Base64.strict_encode64("#{user}:#{pass}") + end + + end + end +end