From f41fed07c83d83e6c095fd9241678832c5810139 Mon Sep 17 00:00:00 2001 From: L F <100371129+duo-lfalsetta@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:41:11 -0500 Subject: [PATCH] Update for sig version 5 and JSON request bodies --- duo_api.gemspec | 3 +- lib/duo_api.rb | 92 +++++++++++++++++++++++++++++++++++--------- test/test_duo_api.rb | 89 ++++++++++++++++++++++++++---------------- 3 files changed, 130 insertions(+), 54 deletions(-) diff --git a/duo_api.gemspec b/duo_api.gemspec index 63209e3..0df060b 100644 --- a/duo_api.gemspec +++ b/duo_api.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |s| s.name = 'duo_api' - s.version = '1.3.0' + s.version = '1.4.0' s.summary = 'Duo API Ruby' s.description = 'A Ruby implementation of the Duo API.' s.email = 'support@duo.com' @@ -15,4 +15,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'rubocop', '~> 0.49.0' s.add_development_dependency 'test-unit', '~> 3.2' s.add_development_dependency 'mocha', '~> 1.8.0' + s.add_development_dependency 'ostruct', '~> 0.1.0' end diff --git a/lib/duo_api.rb b/lib/duo_api.rb index 888923f..9668d54 100644 --- a/lib/duo_api.rb +++ b/lib/duo_api.rb @@ -1,4 +1,5 @@ require 'erb' +require 'json' require 'openssl' require 'net/https' require 'time' @@ -10,6 +11,12 @@ class DuoApi attr_accessor :ca_file + if Gem.loaded_specs['duo_api'] + VERSION = Gem.loaded_specs['duo_api'].version + else + VERSION = '0.0.0' + end + # Constants for handling rate limit backoff MAX_BACKOFF_WAIT_SECS = 32 INITIAL_BACKOFF_WAIT_SECS = 1 @@ -32,26 +39,40 @@ def initialize(ikey, skey, host, proxy = nil, ca_file: nil) ] end @ca_file = ca_file || - File.join(File.dirname(__FILE__), '..', 'ca_certs.pem') + File.join(File.dirname(__FILE__), '..', 'ca_certs.pem') end - def request(method, path, params = nil) + def request(method, path, params = {}, additional_headers = nil) + params_go_in_body = %w[POST PUT PATCH].include?(method) + if params_go_in_body + body = canon_json(params) + params = {} + else + body = '' + end + uri = request_uri(path, params) - current_date, signed = sign(method, uri.host, path, params) + current_date, signed = sign(method, uri.host, path, params, body, additional_headers) request = Net::HTTP.const_get(method.capitalize).new uri.to_s request.basic_auth(@ikey, signed) request['Date'] = current_date - request['User-Agent'] = 'duo_api_ruby/1.3.0' + request['User-Agent'] = "duo_api_ruby/#{VERSION}" + if params_go_in_body + request['Content-Type'] = 'application/json' + request.body = body + end - Net::HTTP.start(uri.host, uri.port, *@proxy, - use_ssl: true, ca_file: @ca_file, - verify_mode: OpenSSL::SSL::VERIFY_PEER) do |http| + Net::HTTP.start( + uri.host, uri.port, *@proxy, + use_ssl: true, ca_file: @ca_file, + verify_mode: OpenSSL::SSL::VERIFY_PEER + ) do |http| wait_secs = INITIAL_BACKOFF_WAIT_SECS while true do resp = http.request(request) if resp.code != RATE_LIMITED_RESP_CODE or wait_secs > MAX_BACKOFF_WAIT_SECS - return resp + return resp end random_offset = rand() sleep(wait_secs + random_offset) @@ -69,7 +90,7 @@ def encode_key_val(k, v) key + '=' + value end - def encode_params(params_hash = nil) + def canon_params(params_hash = nil) return '' if params_hash.nil? params_hash.sort.map do |k, v| # when it is an array, we want to add that as another param @@ -82,30 +103,63 @@ def encode_params(params_hash = nil) end.join('&') end - def time - Time.now.rfc2822 + def canon_json(params_hash = nil) + return '' if params_hash.nil? + JSON.generate(Hash[params_hash.sort]) + end + + def canon_x_duo_headers(additional_headers) + additional_headers ||= {} + + if not additional_headers.select{|k,v| k.nil? or v.nil?}.empty? + raise 'Not allowed "nil" as a header name or value' + end + + canon_list = [] + added_headers = [] + additional_headers.keys.sort.each do |header_name| + header_name_lowered = header_name.downcase + header_value = additional_headers[header_name] + validate_additional_header(header_name_lowered, header_value, added_headers) + canon_list.append(header_name_lowered, header_value) + added_headers.append(header_name_lowered) + end + + canon = canon_list.join("\x00") + OpenSSL::Digest::SHA512.hexdigest(canon) + end + + def validate_additional_header(header_name, value, added_headers) + raise 'Not allowed "Null" character in header name' if header_name.include?("\x00") + raise 'Not allowed "Null" character in header value' if value.include?("\x00") + raise 'Additional headers must start with \'X-Duo-\'' unless header_name.downcase.start_with?('x-duo-') + raise "Duplicate header passed, header=#{header_name}" if added_headers.include?(header_name.downcase) end def request_uri(path, params = nil) u = 'https://' + @host + path - u += '?' + encode_params(params) unless params.nil? + u += '?' + canon_params(params) unless params.nil? URI.parse(u) end - def canonicalize(method, host, path, params, options = {}) - options[:date] ||= time + def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {}) + # options[:date] being passed manually is specifically for tests + date = options[:date] || Time.now.rfc2822() canon = [ - options[:date], + date, method.upcase, host.downcase, path, - encode_params(params) + canon_params(params), + OpenSSL::Digest::SHA512.hexdigest(body), + canon_x_duo_headers(additional_headers) ] - [options[:date], canon.join("\n")] + [date, canon.join("\n")] end - def sign(method, host, path, params, options = {}) - date, canon = canonicalize(method, host, path, params, date: options[:date]) + def sign(method, host, path, params, body = '', additional_headers = nil, options: {}) + # options[:date] being passed manually is specifically for tests + date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options) [date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)] end end diff --git a/test/test_duo_api.rb b/test/test_duo_api.rb index 070fe91..78d72b2 100644 --- a/test/test_duo_api.rb +++ b/test/test_duo_api.rb @@ -26,7 +26,7 @@ def test_default_ca_file_exists class TestQueryParameters < TestCase def assert_canon_params(params, expected) - actual = @client.send(:encode_params, params) + actual = @client.send(:canon_params, params) assert_equal(expected, actual) end @@ -108,21 +108,40 @@ def test_encode_key_val end class TestCanonicalize < TestCase - def test_v2 + def test_sig_v5_params params = { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + body = '' + additional_headers = nil expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' - expected_canon = "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU" + expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" actual_date, actual_canon = @client.send(:canonicalize, - 'PoSt', HOST, '/Foo/BaR2/qux', params, :date => expected_date) + 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date}) assert_equal(expected_canon, actual_canon) assert_equal(expected_date, actual_date) end + def test_sig_v5_json + params_hash = { + "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", + "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", + "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", + "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" + } + params = {} + body = JSON.generate(Hash[params_hash.sort]) + additional_headers = nil + expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' + expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\n069842dc1b1158ce098fb8cbabf4695fe5b6dbbe0189293c45253b80522d6c56aaed43cfeeb541222d5a34d56f57e2b420b70856d1f09ba346418e7a5bca6397\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + actual_date, actual_canon = @client.send(:canonicalize, + 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date}) + assert_equal(expected_canon, actual_canon) + assert_equal(expected_date, actual_date) + end end class TestSign < TestCase @@ -133,10 +152,12 @@ def test_hmac_sha512 "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + body = '' + additional_headers = nil expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' - expected_sig = '0508065035a03b2a1de2f453e629e791d180329e157f65df6b3e0f08299d4321e1c5c7a7c7ee6b9e5fc80d1fb6fbf3ad5eb7c44dd3b3985a02c37aca53ec3698' + expected_sig = 'de886475f5ee8cf32872a7c10869e4dce7a0038f8b0da01d903469c6240473dfd1abf98b40b34b9ad7fbc99d5df3f2279e7105fd9101c428b94faaeec5e179cf' actual_date, actual_sig = @client.send(:sign, - 'PoSt', HOST, '/Foo/BaR2/qux', params, :date => expected_date) + 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date}) assert_equal(expected_sig, actual_sig) assert_equal(expected_date, actual_date) end @@ -144,49 +165,49 @@ def test_hmac_sha512 end class MockResponse < Object - attr_reader :code + attr_reader :code - def initialize(code) - @code = code - end + def initialize(code) + @code = code + end end class TestRetryRequests < TestCase def setup - super - @mock_http = mock() - Net::HTTP.expects(:start).yields(@mock_http) + super + @mock_http = mock() + Net::HTTP.expects(:start).yields(@mock_http) - @limited_response = MockResponse.new('429') - @ok_response = MockResponse.new('200') + @limited_response = MockResponse.new('429') + @ok_response = MockResponse.new('200') end def test_non_limited_response - @mock_http.expects(:request).returns(@ok_response) - @client.expects(:sleep).never - actual_response = @client.request('GET', '/foo/bar') - assert_equal(@ok_response, actual_response) + @mock_http.expects(:request).returns(@ok_response) + @client.expects(:sleep).never + actual_response = @client.request('GET', '/foo/bar') + assert_equal(@ok_response, actual_response) end def test_single_limited_response - @mock_http.expects(:request).twice.returns(@limited_response, @ok_response) - @client.expects(:rand).returns(0.123) - @client.expects(:sleep).with(1.123) - actual_response = @client.request('GET', '/foo/bar') - assert_equal(@ok_response, actual_response) + @mock_http.expects(:request).twice.returns(@limited_response, @ok_response) + @client.expects(:rand).returns(0.123) + @client.expects(:sleep).with(1.123) + actual_response = @client.request('GET', '/foo/bar') + assert_equal(@ok_response, actual_response) end def test_all_limited_responses - @mock_http.expects(:request).times(7).returns(@limited_response) - @client.expects(:rand).times(6).returns(0.123) - @client.expects(:sleep).with(1.123) - @client.expects(:sleep).with(2.123) - @client.expects(:sleep).with(4.123) - @client.expects(:sleep).with(8.123) - @client.expects(:sleep).with(16.123) - @client.expects(:sleep).with(32.123) - actual_response = @client.request('GET', '/foo/bar') - assert_equal(@limited_response, actual_response) + @mock_http.expects(:request).times(7).returns(@limited_response) + @client.expects(:rand).times(6).returns(0.123) + @client.expects(:sleep).with(1.123) + @client.expects(:sleep).with(2.123) + @client.expects(:sleep).with(4.123) + @client.expects(:sleep).with(8.123) + @client.expects(:sleep).with(16.123) + @client.expects(:sleep).with(32.123) + actual_response = @client.request('GET', '/foo/bar') + assert_equal(@limited_response, actual_response) end end