Skip to content

Commit 3e3b7d9

Browse files
authored
Merge pull request #40 from duo-lfalsetta/sig_v5
Update for sig version 5 and JSON request bodies
2 parents 27e8339 + f41fed0 commit 3e3b7d9

File tree

3 files changed

+130
-54
lines changed

3 files changed

+130
-54
lines changed

duo_api.gemspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Gem::Specification.new do |s|
22
s.name = 'duo_api'
3-
s.version = '1.3.0'
3+
s.version = '1.4.0'
44
s.summary = 'Duo API Ruby'
55
s.description = 'A Ruby implementation of the Duo API.'
66
s.email = 'support@duo.com'
@@ -15,4 +15,5 @@ Gem::Specification.new do |s|
1515
s.add_development_dependency 'rubocop', '~> 0.49.0'
1616
s.add_development_dependency 'test-unit', '~> 3.2'
1717
s.add_development_dependency 'mocha', '~> 1.8.0'
18+
s.add_development_dependency 'ostruct', '~> 0.1.0'
1819
end

lib/duo_api.rb

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'erb'
2+
require 'json'
23
require 'openssl'
34
require 'net/https'
45
require 'time'
@@ -10,6 +11,12 @@
1011
class DuoApi
1112
attr_accessor :ca_file
1213

14+
if Gem.loaded_specs['duo_api']
15+
VERSION = Gem.loaded_specs['duo_api'].version
16+
else
17+
VERSION = '0.0.0'
18+
end
19+
1320
# Constants for handling rate limit backoff
1421
MAX_BACKOFF_WAIT_SECS = 32
1522
INITIAL_BACKOFF_WAIT_SECS = 1
@@ -32,26 +39,40 @@ def initialize(ikey, skey, host, proxy = nil, ca_file: nil)
3239
]
3340
end
3441
@ca_file = ca_file ||
35-
File.join(File.dirname(__FILE__), '..', 'ca_certs.pem')
42+
File.join(File.dirname(__FILE__), '..', 'ca_certs.pem')
3643
end
3744

38-
def request(method, path, params = nil)
45+
def request(method, path, params = {}, additional_headers = nil)
46+
params_go_in_body = %w[POST PUT PATCH].include?(method)
47+
if params_go_in_body
48+
body = canon_json(params)
49+
params = {}
50+
else
51+
body = ''
52+
end
53+
3954
uri = request_uri(path, params)
40-
current_date, signed = sign(method, uri.host, path, params)
55+
current_date, signed = sign(method, uri.host, path, params, body, additional_headers)
4156

4257
request = Net::HTTP.const_get(method.capitalize).new uri.to_s
4358
request.basic_auth(@ikey, signed)
4459
request['Date'] = current_date
45-
request['User-Agent'] = 'duo_api_ruby/1.3.0'
60+
request['User-Agent'] = "duo_api_ruby/#{VERSION}"
61+
if params_go_in_body
62+
request['Content-Type'] = 'application/json'
63+
request.body = body
64+
end
4665

47-
Net::HTTP.start(uri.host, uri.port, *@proxy,
48-
use_ssl: true, ca_file: @ca_file,
49-
verify_mode: OpenSSL::SSL::VERIFY_PEER) do |http|
66+
Net::HTTP.start(
67+
uri.host, uri.port, *@proxy,
68+
use_ssl: true, ca_file: @ca_file,
69+
verify_mode: OpenSSL::SSL::VERIFY_PEER
70+
) do |http|
5071
wait_secs = INITIAL_BACKOFF_WAIT_SECS
5172
while true do
5273
resp = http.request(request)
5374
if resp.code != RATE_LIMITED_RESP_CODE or wait_secs > MAX_BACKOFF_WAIT_SECS
54-
return resp
75+
return resp
5576
end
5677
random_offset = rand()
5778
sleep(wait_secs + random_offset)
@@ -69,7 +90,7 @@ def encode_key_val(k, v)
6990
key + '=' + value
7091
end
7192

72-
def encode_params(params_hash = nil)
93+
def canon_params(params_hash = nil)
7394
return '' if params_hash.nil?
7495
params_hash.sort.map do |k, v|
7596
# when it is an array, we want to add that as another param
@@ -82,30 +103,63 @@ def encode_params(params_hash = nil)
82103
end.join('&')
83104
end
84105

85-
def time
86-
Time.now.rfc2822
106+
def canon_json(params_hash = nil)
107+
return '' if params_hash.nil?
108+
JSON.generate(Hash[params_hash.sort])
109+
end
110+
111+
def canon_x_duo_headers(additional_headers)
112+
additional_headers ||= {}
113+
114+
if not additional_headers.select{|k,v| k.nil? or v.nil?}.empty?
115+
raise 'Not allowed "nil" as a header name or value'
116+
end
117+
118+
canon_list = []
119+
added_headers = []
120+
additional_headers.keys.sort.each do |header_name|
121+
header_name_lowered = header_name.downcase
122+
header_value = additional_headers[header_name]
123+
validate_additional_header(header_name_lowered, header_value, added_headers)
124+
canon_list.append(header_name_lowered, header_value)
125+
added_headers.append(header_name_lowered)
126+
end
127+
128+
canon = canon_list.join("\x00")
129+
OpenSSL::Digest::SHA512.hexdigest(canon)
130+
end
131+
132+
def validate_additional_header(header_name, value, added_headers)
133+
raise 'Not allowed "Null" character in header name' if header_name.include?("\x00")
134+
raise 'Not allowed "Null" character in header value' if value.include?("\x00")
135+
raise 'Additional headers must start with \'X-Duo-\'' unless header_name.downcase.start_with?('x-duo-')
136+
raise "Duplicate header passed, header=#{header_name}" if added_headers.include?(header_name.downcase)
87137
end
88138

89139
def request_uri(path, params = nil)
90140
u = 'https://' + @host + path
91-
u += '?' + encode_params(params) unless params.nil?
141+
u += '?' + canon_params(params) unless params.nil?
92142
URI.parse(u)
93143
end
94144

95-
def canonicalize(method, host, path, params, options = {})
96-
options[:date] ||= time
145+
def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {})
146+
# options[:date] being passed manually is specifically for tests
147+
date = options[:date] || Time.now.rfc2822()
97148
canon = [
98-
options[:date],
149+
date,
99150
method.upcase,
100151
host.downcase,
101152
path,
102-
encode_params(params)
153+
canon_params(params),
154+
OpenSSL::Digest::SHA512.hexdigest(body),
155+
canon_x_duo_headers(additional_headers)
103156
]
104-
[options[:date], canon.join("\n")]
157+
[date, canon.join("\n")]
105158
end
106159

107-
def sign(method, host, path, params, options = {})
108-
date, canon = canonicalize(method, host, path, params, date: options[:date])
160+
def sign(method, host, path, params, body = '', additional_headers = nil, options: {})
161+
# options[:date] being passed manually is specifically for tests
162+
date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options)
109163
[date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)]
110164
end
111165
end

test/test_duo_api.rb

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def test_default_ca_file_exists
2626
class TestQueryParameters < TestCase
2727

2828
def assert_canon_params(params, expected)
29-
actual = @client.send(:encode_params, params)
29+
actual = @client.send(:canon_params, params)
3030
assert_equal(expected, actual)
3131
end
3232

@@ -108,21 +108,40 @@ def test_encode_key_val
108108
end
109109

110110
class TestCanonicalize < TestCase
111-
def test_v2
111+
def test_sig_v5_params
112112
params = {
113113
"\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0",
114114
"\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30",
115115
"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3",
116116
"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"
117117
}
118+
body = ''
119+
additional_headers = nil
118120
expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000'
119-
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"
121+
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"
120122
actual_date, actual_canon = @client.send(:canonicalize,
121-
'PoSt', HOST, '/Foo/BaR2/qux', params, :date => expected_date)
123+
'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date})
122124
assert_equal(expected_canon, actual_canon)
123125
assert_equal(expected_date, actual_date)
124126
end
125127

128+
def test_sig_v5_json
129+
params_hash = {
130+
"\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0",
131+
"\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30",
132+
"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3",
133+
"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"
134+
}
135+
params = {}
136+
body = JSON.generate(Hash[params_hash.sort])
137+
additional_headers = nil
138+
expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000'
139+
expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\n069842dc1b1158ce098fb8cbabf4695fe5b6dbbe0189293c45253b80522d6c56aaed43cfeeb541222d5a34d56f57e2b420b70856d1f09ba346418e7a5bca6397\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
140+
actual_date, actual_canon = @client.send(:canonicalize,
141+
'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date})
142+
assert_equal(expected_canon, actual_canon)
143+
assert_equal(expected_date, actual_date)
144+
end
126145
end
127146

128147
class TestSign < TestCase
@@ -133,60 +152,62 @@ def test_hmac_sha512
133152
"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3",
134153
"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"
135154
}
155+
body = ''
156+
additional_headers = nil
136157
expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000'
137-
expected_sig = '0508065035a03b2a1de2f453e629e791d180329e157f65df6b3e0f08299d4321e1c5c7a7c7ee6b9e5fc80d1fb6fbf3ad5eb7c44dd3b3985a02c37aca53ec3698'
158+
expected_sig = 'de886475f5ee8cf32872a7c10869e4dce7a0038f8b0da01d903469c6240473dfd1abf98b40b34b9ad7fbc99d5df3f2279e7105fd9101c428b94faaeec5e179cf'
138159
actual_date, actual_sig = @client.send(:sign,
139-
'PoSt', HOST, '/Foo/BaR2/qux', params, :date => expected_date)
160+
'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date})
140161
assert_equal(expected_sig, actual_sig)
141162
assert_equal(expected_date, actual_date)
142163
end
143164

144165
end
145166

146167
class MockResponse < Object
147-
attr_reader :code
168+
attr_reader :code
148169

149-
def initialize(code)
150-
@code = code
151-
end
170+
def initialize(code)
171+
@code = code
172+
end
152173
end
153174

154175
class TestRetryRequests < TestCase
155176
def setup
156-
super
157-
@mock_http = mock()
158-
Net::HTTP.expects(:start).yields(@mock_http)
177+
super
178+
@mock_http = mock()
179+
Net::HTTP.expects(:start).yields(@mock_http)
159180

160-
@limited_response = MockResponse.new('429')
161-
@ok_response = MockResponse.new('200')
181+
@limited_response = MockResponse.new('429')
182+
@ok_response = MockResponse.new('200')
162183
end
163184

164185
def test_non_limited_response
165-
@mock_http.expects(:request).returns(@ok_response)
166-
@client.expects(:sleep).never
167-
actual_response = @client.request('GET', '/foo/bar')
168-
assert_equal(@ok_response, actual_response)
186+
@mock_http.expects(:request).returns(@ok_response)
187+
@client.expects(:sleep).never
188+
actual_response = @client.request('GET', '/foo/bar')
189+
assert_equal(@ok_response, actual_response)
169190
end
170191

171192
def test_single_limited_response
172-
@mock_http.expects(:request).twice.returns(@limited_response, @ok_response)
173-
@client.expects(:rand).returns(0.123)
174-
@client.expects(:sleep).with(1.123)
175-
actual_response = @client.request('GET', '/foo/bar')
176-
assert_equal(@ok_response, actual_response)
193+
@mock_http.expects(:request).twice.returns(@limited_response, @ok_response)
194+
@client.expects(:rand).returns(0.123)
195+
@client.expects(:sleep).with(1.123)
196+
actual_response = @client.request('GET', '/foo/bar')
197+
assert_equal(@ok_response, actual_response)
177198
end
178199

179200
def test_all_limited_responses
180-
@mock_http.expects(:request).times(7).returns(@limited_response)
181-
@client.expects(:rand).times(6).returns(0.123)
182-
@client.expects(:sleep).with(1.123)
183-
@client.expects(:sleep).with(2.123)
184-
@client.expects(:sleep).with(4.123)
185-
@client.expects(:sleep).with(8.123)
186-
@client.expects(:sleep).with(16.123)
187-
@client.expects(:sleep).with(32.123)
188-
actual_response = @client.request('GET', '/foo/bar')
189-
assert_equal(@limited_response, actual_response)
201+
@mock_http.expects(:request).times(7).returns(@limited_response)
202+
@client.expects(:rand).times(6).returns(0.123)
203+
@client.expects(:sleep).with(1.123)
204+
@client.expects(:sleep).with(2.123)
205+
@client.expects(:sleep).with(4.123)
206+
@client.expects(:sleep).with(8.123)
207+
@client.expects(:sleep).with(16.123)
208+
@client.expects(:sleep).with(32.123)
209+
actual_response = @client.request('GET', '/foo/bar')
210+
assert_equal(@limited_response, actual_response)
190211
end
191212

192213
end

0 commit comments

Comments
 (0)