duo_api 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ca_certs.pem +453 -85
- data/lib/duo_api/accounts.rb +70 -0
- data/lib/duo_api/admin.rb +832 -0
- data/lib/duo_api/api_client.rb +204 -0
- data/lib/duo_api/api_helpers.rb +195 -0
- data/lib/duo_api/auth.rb +63 -0
- data/lib/duo_api/device.rb +62 -0
- data/lib/duo_api.rb +7 -163
- metadata +38 -18
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'erb'
|
5
|
+
require 'json'
|
6
|
+
require 'openssl'
|
7
|
+
require 'net/https'
|
8
|
+
require 'time'
|
9
|
+
require 'uri'
|
10
|
+
|
11
|
+
##
|
12
|
+
# A Ruby implementation of the Duo API
|
13
|
+
#
|
14
|
+
class DuoApi
|
15
|
+
attr_accessor :ca_file
|
16
|
+
attr_reader :default_params
|
17
|
+
|
18
|
+
VERSION = Gem.loaded_specs['duo_api'] ? Gem.loaded_specs['duo_api'].version : '0.0.0'
|
19
|
+
|
20
|
+
# Constants for handling rate limit backoff
|
21
|
+
MAX_BACKOFF_WAIT_SECS = 32
|
22
|
+
INITIAL_BACKOFF_WAIT_SECS = 1
|
23
|
+
BACKOFF_FACTOR = 2
|
24
|
+
|
25
|
+
def initialize(ikey, skey, host, proxy = nil, ca_file: nil, default_params: {})
|
26
|
+
@ikey = ikey
|
27
|
+
@skey = skey
|
28
|
+
@host = host
|
29
|
+
@proxy_str = proxy
|
30
|
+
if proxy.nil?
|
31
|
+
@proxy = []
|
32
|
+
else
|
33
|
+
proxy_uri = URI.parse proxy
|
34
|
+
@proxy = [
|
35
|
+
proxy_uri.host,
|
36
|
+
proxy_uri.port,
|
37
|
+
proxy_uri.user,
|
38
|
+
proxy_uri.password
|
39
|
+
]
|
40
|
+
end
|
41
|
+
@ca_file = ca_file ||
|
42
|
+
File.join(File.dirname(__FILE__), '..', '..', 'ca_certs.pem')
|
43
|
+
@default_params = default_params.transform_keys(&:to_sym)
|
44
|
+
end
|
45
|
+
|
46
|
+
def default_params=(default_params)
|
47
|
+
@default_params = default_params.transform_keys(&:to_sym)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Basic authenticated request returning raw Net::HTTPResponse object
|
51
|
+
def request(method, path, params = {}, additional_headers = nil)
|
52
|
+
# Merge default params with provided params
|
53
|
+
params = @default_params.merge(params.transform_keys(&:to_sym))
|
54
|
+
|
55
|
+
# Determine if params should be in a JSON request body
|
56
|
+
params_go_in_body = %w[POST PUT PATCH].include?(method)
|
57
|
+
if params_go_in_body
|
58
|
+
body = canon_json(params)
|
59
|
+
params = {}
|
60
|
+
else
|
61
|
+
body = ''
|
62
|
+
end
|
63
|
+
|
64
|
+
# Construct the request URI
|
65
|
+
uri = request_uri(path, params)
|
66
|
+
|
67
|
+
# Sign the request
|
68
|
+
current_date, signed = sign(method, uri.host, path, params, body, additional_headers)
|
69
|
+
|
70
|
+
# Create the HTTP request object
|
71
|
+
request = Net::HTTP.const_get(method.capitalize).new(uri.to_s)
|
72
|
+
request.basic_auth(@ikey, signed)
|
73
|
+
request['Date'] = current_date
|
74
|
+
request['User-Agent'] = "duo_api_ruby/#{VERSION}"
|
75
|
+
|
76
|
+
# Set Content-Type and request body for JSON requests
|
77
|
+
if params_go_in_body
|
78
|
+
request['Content-Type'] = 'application/json'
|
79
|
+
request.body = body
|
80
|
+
end
|
81
|
+
|
82
|
+
# Start the HTTP session
|
83
|
+
Net::HTTP.start(
|
84
|
+
uri.host, uri.port, *@proxy,
|
85
|
+
use_ssl: true, ca_file: @ca_file,
|
86
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER
|
87
|
+
) do |http|
|
88
|
+
wait_secs = INITIAL_BACKOFF_WAIT_SECS
|
89
|
+
loop do
|
90
|
+
resp = http.request(request)
|
91
|
+
|
92
|
+
# Check if the response is rate-limited and handle backoff
|
93
|
+
return resp if !resp.is_a?(Net::HTTPTooManyRequests) || (wait_secs > MAX_BACKOFF_WAIT_SECS)
|
94
|
+
|
95
|
+
random_offset = rand
|
96
|
+
sleep(wait_secs + random_offset)
|
97
|
+
wait_secs *= BACKOFF_FACTOR
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
# Encode a key-value pair for a URL
|
105
|
+
def encode_key_val(key, val)
|
106
|
+
key = ERB::Util.url_encode(key.to_s)
|
107
|
+
value = ERB::Util.url_encode(val.to_s)
|
108
|
+
"#{key}=#{value}"
|
109
|
+
end
|
110
|
+
|
111
|
+
# Build a canonical parameter string
|
112
|
+
def canon_params(params_hash = nil)
|
113
|
+
return '' if params_hash.nil?
|
114
|
+
|
115
|
+
params_hash.transform_keys(&:to_s).sort.map do |k, v|
|
116
|
+
# When value an array, repeat key for each unique value in sorted array
|
117
|
+
if v.is_a?(Array)
|
118
|
+
if v.count.positive?
|
119
|
+
v.sort.uniq.map{ |vn| encode_key_val(k, vn) }.join('&')
|
120
|
+
else
|
121
|
+
encode_key_val(k, '')
|
122
|
+
end
|
123
|
+
else
|
124
|
+
encode_key_val(k, v)
|
125
|
+
end
|
126
|
+
end.join('&')
|
127
|
+
end
|
128
|
+
|
129
|
+
# Generate a canonical JSON body
|
130
|
+
def canon_json(params_hash = nil)
|
131
|
+
return '' if params_hash.nil?
|
132
|
+
|
133
|
+
JSON.generate(params_hash.sort.to_h)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Canonicalize additional headers for signing
|
137
|
+
def canon_x_duo_headers(additional_headers)
|
138
|
+
additional_headers ||= {}
|
139
|
+
|
140
|
+
unless additional_headers.none?{ |k, v| k.nil? || v.nil? }
|
141
|
+
raise(HeaderError, 'Not allowed "nil" as a header name or value')
|
142
|
+
end
|
143
|
+
|
144
|
+
canon_list = []
|
145
|
+
added_headers = []
|
146
|
+
additional_headers.keys.sort.each do |header_name|
|
147
|
+
header_name_lowered = header_name.downcase
|
148
|
+
header_value = additional_headers[header_name]
|
149
|
+
validate_additional_header(header_name_lowered, header_value, added_headers)
|
150
|
+
canon_list.append(header_name_lowered, header_value)
|
151
|
+
added_headers.append(header_name_lowered)
|
152
|
+
end
|
153
|
+
|
154
|
+
canon = canon_list.join("\x00")
|
155
|
+
OpenSSL::Digest::SHA512.hexdigest(canon)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Validate additional headers to ensure they meet requirements
|
159
|
+
def validate_additional_header(header_name, value, added_headers)
|
160
|
+
header_name.downcase!
|
161
|
+
raise(HeaderError, 'Not allowed "Null" character in header name') if header_name.include?("\x00")
|
162
|
+
raise(HeaderError, 'Not allowed "Null" character in header value') if value.include?("\x00")
|
163
|
+
raise(HeaderError, 'Additional headers must start with \'X-Duo-\'') unless header_name.start_with?('x-duo-')
|
164
|
+
raise(HeaderError, "Duplicate header passed, header=#{header_name}") if added_headers.include?(header_name)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Construct the request URI
|
168
|
+
def request_uri(path, params = nil)
|
169
|
+
u = "https://#{@host}#{path}"
|
170
|
+
u += "?#{canon_params(params)}" unless params.nil?
|
171
|
+
URI.parse(u)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Create a canonical string for signing requests
|
175
|
+
def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {})
|
176
|
+
# options[:date] being passed manually is specifically for tests
|
177
|
+
date = options[:date] || Time.now.rfc2822
|
178
|
+
canon = [
|
179
|
+
date,
|
180
|
+
method.upcase,
|
181
|
+
host.downcase,
|
182
|
+
path,
|
183
|
+
canon_params(params),
|
184
|
+
OpenSSL::Digest::SHA512.hexdigest(body),
|
185
|
+
canon_x_duo_headers(additional_headers)
|
186
|
+
]
|
187
|
+
[date, canon.join("\n")]
|
188
|
+
end
|
189
|
+
|
190
|
+
# Sign the request with HMAC-SHA512
|
191
|
+
def sign(method, host, path, params, body = '', additional_headers = nil, options: {})
|
192
|
+
# options[:date] being passed manually is specifically for tests
|
193
|
+
date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options)
|
194
|
+
[date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)]
|
195
|
+
end
|
196
|
+
|
197
|
+
# Custom Error Classes
|
198
|
+
class HeaderError < StandardError; end
|
199
|
+
class RateLimitError < StandardError; end
|
200
|
+
class ResponseCodeError < StandardError; end
|
201
|
+
class ContentTypeError < StandardError; end
|
202
|
+
class PaginationError < StandardError; end
|
203
|
+
class ChildAccountError < StandardError; end
|
204
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'api_client'
|
4
|
+
|
5
|
+
# Extend DuoApi class with some HTTP method helpers
|
6
|
+
class DuoApi
|
7
|
+
# Perform a GET request and parse the response as JSON
|
8
|
+
def get(path, params = {}, additional_headers = nil)
|
9
|
+
resp = request('GET', path, params, additional_headers)
|
10
|
+
raise_http_errors(resp)
|
11
|
+
raise_content_type_errors(resp[:'content-type'], 'application/json')
|
12
|
+
|
13
|
+
parse_json_to_sym_hash(resp.body)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Perform a GET request and retrieve all paginated JSON data
|
17
|
+
def get_all(path, params = {}, additional_headers = nil, data_array_path: nil, metadata_path: nil)
|
18
|
+
# Set default paths for returned data array and metadata if not provided
|
19
|
+
data_array_path = if data_array_path.is_a?(Array) && (data_array_path.count >= 1)
|
20
|
+
data_array_path.map(&:to_sym)
|
21
|
+
else
|
22
|
+
[:response]
|
23
|
+
end
|
24
|
+
metadata_path = if metadata_path.is_a?(Array) && (metadata_path.count >= 1)
|
25
|
+
metadata_path.map(&:to_sym)
|
26
|
+
else
|
27
|
+
[:metadata]
|
28
|
+
end
|
29
|
+
|
30
|
+
# Ensure params keys are symbols and ignore offset parameters
|
31
|
+
params.transform_keys!(&:to_sym)
|
32
|
+
%i[offset next_offset].each do |p|
|
33
|
+
if params[p]
|
34
|
+
warn "Ignoring supplied #{p} parameter for get_all method"
|
35
|
+
params.delete(p)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
# Default :limit to 1000 unless specified to minimize requests
|
39
|
+
params[:limit] ||= 1000
|
40
|
+
|
41
|
+
all_data = []
|
42
|
+
prev_results_count = 0
|
43
|
+
next_offset = 0
|
44
|
+
prev_offset = 0
|
45
|
+
resp_body_hash = {}
|
46
|
+
loop do
|
47
|
+
resp = request('GET', path, params, additional_headers)
|
48
|
+
raise_http_errors(resp)
|
49
|
+
raise_content_type_errors(resp[:'content-type'], 'application/json')
|
50
|
+
|
51
|
+
resp_body_hash = parse_json_to_sym_hash(resp.body)
|
52
|
+
resp_data_array = resp_body_hash.dig(*data_array_path)
|
53
|
+
unless resp_data_array.is_a?(Array)
|
54
|
+
raise(PaginationError,
|
55
|
+
"Object at data_array_path #{JSON.generate(data_array_path)} is not an Array")
|
56
|
+
end
|
57
|
+
all_data.concat(resp_data_array)
|
58
|
+
|
59
|
+
resp_metadata = resp_body_hash.dig(*metadata_path)
|
60
|
+
if resp_metadata.is_a?(Hash) && resp_metadata[:next_offset]
|
61
|
+
next_offset = resp_metadata[:next_offset]
|
62
|
+
next_offset = next_offset.to_i if string_int?(next_offset)
|
63
|
+
|
64
|
+
if next_offset.is_a?(Array) || next_offset.is_a?(String)
|
65
|
+
next_offset = next_offset.join(',') if next_offset.is_a?(Array)
|
66
|
+
raise(PaginationError, 'Paginated response offset error') if next_offset == prev_offset
|
67
|
+
|
68
|
+
params[:next_offset] = next_offset
|
69
|
+
else
|
70
|
+
raise(PaginationError, 'Paginated response offset error') if next_offset <= prev_offset
|
71
|
+
|
72
|
+
params[:offset] = next_offset
|
73
|
+
end
|
74
|
+
else
|
75
|
+
next_offset = nil
|
76
|
+
params.delete(:offset)
|
77
|
+
params.delete(:next_offset)
|
78
|
+
end
|
79
|
+
|
80
|
+
break if !next_offset ||
|
81
|
+
(all_data.count <= prev_results_count)
|
82
|
+
|
83
|
+
prev_results_count = all_data.count
|
84
|
+
prev_offset = next_offset
|
85
|
+
end
|
86
|
+
|
87
|
+
# Replace the data array in the last returned resp_body_hash with the all_data array
|
88
|
+
data_array_parent_hash = if data_array_path.count > 1
|
89
|
+
resp_body_hash.dig(*data_array_path[0..-2])
|
90
|
+
else
|
91
|
+
resp_body_hash
|
92
|
+
end
|
93
|
+
data_array_key = data_array_path.last
|
94
|
+
data_array_parent_hash[data_array_key] = all_data
|
95
|
+
|
96
|
+
resp_body_hash
|
97
|
+
end
|
98
|
+
|
99
|
+
# Perform a GET request to retrieve image data and return raw data
|
100
|
+
def get_image(path, params = {}, additional_headers = nil)
|
101
|
+
resp = request('GET', path, params, additional_headers)
|
102
|
+
raise_http_errors(resp)
|
103
|
+
raise_content_type_errors(resp[:'content-type'], %r{^image/})
|
104
|
+
|
105
|
+
resp.body
|
106
|
+
end
|
107
|
+
|
108
|
+
# Perform a POST request and parse the response as JSON
|
109
|
+
def post(path, params = {}, additional_headers = nil)
|
110
|
+
resp = request('POST', path, params, additional_headers)
|
111
|
+
raise_http_errors(resp)
|
112
|
+
raise_content_type_errors(resp[:'content-type'], 'application/json')
|
113
|
+
|
114
|
+
parse_json_to_sym_hash(resp.body)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Perform a PUT request and parse the response as JSON
|
118
|
+
def put(path, params = {}, additional_headers = nil)
|
119
|
+
resp = request('PUT', path, params, additional_headers)
|
120
|
+
raise_http_errors(resp)
|
121
|
+
raise_content_type_errors(resp[:'content-type'], 'application/json')
|
122
|
+
|
123
|
+
parse_json_to_sym_hash(resp.body)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Perform a DELETE request and parse the response as JSON
|
127
|
+
def delete(path, params = {}, additional_headers = nil)
|
128
|
+
resp = request('DELETE', path, params, additional_headers)
|
129
|
+
raise_http_errors(resp)
|
130
|
+
raise_content_type_errors(resp[:'content-type'], 'application/json')
|
131
|
+
|
132
|
+
parse_json_to_sym_hash(resp.body)
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
# Raise errors for non-successful HTTP responses
|
138
|
+
def raise_http_errors(resp)
|
139
|
+
return if resp.is_a?(Net::HTTPSuccess)
|
140
|
+
raise(RateLimitError, 'Rate limit retry max wait exceeded') if resp.is_a?(Net::HTTPTooManyRequests)
|
141
|
+
|
142
|
+
raise(ResponseCodeError, "HTTP #{resp.code}: #{resp.body}")
|
143
|
+
end
|
144
|
+
|
145
|
+
# Validate the content type of the response against the expected type
|
146
|
+
def raise_content_type_errors(received, allowed)
|
147
|
+
valid = false
|
148
|
+
if allowed.is_a?(Regexp)
|
149
|
+
valid = true if received =~ allowed
|
150
|
+
elsif received == allowed
|
151
|
+
valid = true
|
152
|
+
end
|
153
|
+
raise(ContentTypeError, "Invalid Content-Type #{received}, should match #{allowed.inspect}") unless valid
|
154
|
+
end
|
155
|
+
|
156
|
+
# Check if a value is a Base64 encoded string
|
157
|
+
def base64?(value)
|
158
|
+
value.is_a?(String) and Base64.strict_encode64(Base64.decode64(value)) == value
|
159
|
+
end
|
160
|
+
|
161
|
+
# Check if a string represents an integer
|
162
|
+
def string_int?(value)
|
163
|
+
value.is_a?(String) and value.to_i.to_s == value
|
164
|
+
end
|
165
|
+
|
166
|
+
# Parse JSON string to Hash with symbol keys
|
167
|
+
def parse_json_to_sym_hash(json)
|
168
|
+
JSON.parse(json, symbolize_names: true)
|
169
|
+
end
|
170
|
+
|
171
|
+
# JSON serialize Array
|
172
|
+
def json_serialized_array(value)
|
173
|
+
value.is_a?(Array) ? JSON.generate(value) : value
|
174
|
+
end
|
175
|
+
|
176
|
+
# CSV serialize Array
|
177
|
+
def csv_serialized_array(value)
|
178
|
+
value.is_a?(Array) ? value.join(',') : value
|
179
|
+
end
|
180
|
+
|
181
|
+
# Format boolean as 'true' or 'false'
|
182
|
+
def stringified_boolean(value)
|
183
|
+
%w[true 1].include?(value.to_s.downcase) ? 'true' : 'false'
|
184
|
+
end
|
185
|
+
|
186
|
+
# Format boolean as '1' or '0'
|
187
|
+
def stringified_binary_boolean(value)
|
188
|
+
%w[true 1].include?(value.to_s.downcase) ? '1' : '0'
|
189
|
+
end
|
190
|
+
|
191
|
+
# Format boolean as 'True' or 'False'
|
192
|
+
def stringified_python_boolean(value)
|
193
|
+
%w[true 1].include?(value.to_s.downcase) ? 'True' : 'False'
|
194
|
+
end
|
195
|
+
end
|
data/lib/duo_api/auth.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'api_client'
|
4
|
+
require_relative 'api_helpers'
|
5
|
+
|
6
|
+
class DuoApi
|
7
|
+
##
|
8
|
+
# Duo Auth API (https://duo.com/docs/authapi)
|
9
|
+
#
|
10
|
+
class Auth < DuoApi
|
11
|
+
def ping
|
12
|
+
get('/auth/v2/ping')[:response]
|
13
|
+
end
|
14
|
+
|
15
|
+
def check
|
16
|
+
get('/auth/v2/check')[:response]
|
17
|
+
end
|
18
|
+
|
19
|
+
def logo
|
20
|
+
get_image('/auth/v2/logo')
|
21
|
+
end
|
22
|
+
|
23
|
+
def enroll(**optional_params)
|
24
|
+
# optional_params: username, valid_secs
|
25
|
+
post('/auth/v2/enroll', optional_params)[:response]
|
26
|
+
end
|
27
|
+
|
28
|
+
def enroll_status(user_id:, activation_code:)
|
29
|
+
params = { user_id: user_id, activation_code: activation_code }
|
30
|
+
post('/auth/v2/enroll_status', params)[:response]
|
31
|
+
end
|
32
|
+
|
33
|
+
def preauth(**optional_params)
|
34
|
+
# optional_params: user_id, username, client_supports_verified_push, ipaddr, hostname,
|
35
|
+
# trusted_device_token
|
36
|
+
#
|
37
|
+
# Note: user_id or username must be provided
|
38
|
+
optional_params.tap do |p|
|
39
|
+
if p[:client_supports_verified_push]
|
40
|
+
p[:client_supports_verified_push] =
|
41
|
+
stringified_binary_boolean(p[:client_supports_verified_push])
|
42
|
+
end
|
43
|
+
end
|
44
|
+
post('/auth/v2/preauth', optional_params)[:response]
|
45
|
+
end
|
46
|
+
|
47
|
+
def auth(factor:, **optional_params)
|
48
|
+
# optional_params: user_id, username, ipaddr, hostname, async
|
49
|
+
#
|
50
|
+
# Note: user_id or username must be provided
|
51
|
+
optional_params.tap do |p|
|
52
|
+
p[:async] = stringified_binary_boolean(p[:async]) if p[:async]
|
53
|
+
end
|
54
|
+
params = optional_params.merge({ factor: factor })
|
55
|
+
post('/auth/v2/auth', params)[:response]
|
56
|
+
end
|
57
|
+
|
58
|
+
def auth_status(txid:)
|
59
|
+
params = { txid: txid }
|
60
|
+
get('/auth/v2/auth_status', params)[:response]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'api_client'
|
4
|
+
require_relative 'api_helpers'
|
5
|
+
|
6
|
+
class DuoApi
|
7
|
+
##
|
8
|
+
# Duo Device API (https://duo.com/docs/deviceapi)
|
9
|
+
#
|
10
|
+
class Device < DuoApi
|
11
|
+
attr_accessor :mkey
|
12
|
+
|
13
|
+
def initialize(ikey, skey, host, proxy = nil, mkey:, ca_file: nil, default_params: {})
|
14
|
+
super(ikey, skey, host, proxy, ca_file: ca_file, default_params: default_params)
|
15
|
+
|
16
|
+
@mkey = mkey
|
17
|
+
end
|
18
|
+
|
19
|
+
def create_device_cache(**optional_params)
|
20
|
+
# optional_params: active
|
21
|
+
optional_params.tap do |p|
|
22
|
+
p[:active] = stringified_python_boolean(p[:active]) if p[:active]
|
23
|
+
end
|
24
|
+
post("/device/v1/management_systems/#{@mkey}/device_cache", optional_params)[:response]
|
25
|
+
end
|
26
|
+
|
27
|
+
def add_device_cache_devices(cache_key:, devices:)
|
28
|
+
params = { devices: devices }
|
29
|
+
post("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", params)[:response]
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_device_cache_devices(cache_key:, **optional_params)
|
33
|
+
# optional_params: device_ids
|
34
|
+
data_array_path = %i[response devices_retrieved]
|
35
|
+
metadata_path = %i[response]
|
36
|
+
get_all("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", optional_params,
|
37
|
+
data_array_path: data_array_path, metadata_path: metadata_path).dig(*data_array_path)
|
38
|
+
end
|
39
|
+
|
40
|
+
def delete_device_cache_devices(cache_key:, devices:)
|
41
|
+
params = { devices: devices }
|
42
|
+
delete("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", params)[:response]
|
43
|
+
end
|
44
|
+
|
45
|
+
def activate_device_cache(cache_key:)
|
46
|
+
post("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/activate")[:response]
|
47
|
+
end
|
48
|
+
|
49
|
+
def delete_device_cache(cache_key:)
|
50
|
+
delete("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}")[:response]
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_device_caches(status:)
|
54
|
+
params = { status: status }
|
55
|
+
get("/device/v1/management_systems/#{@mkey}/device_cache", params)[:response]
|
56
|
+
end
|
57
|
+
|
58
|
+
def get_device_cache(cache_key:)
|
59
|
+
get("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}")[:response]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
data/lib/duo_api.rb
CHANGED
@@ -1,165 +1,9 @@
|
|
1
|
-
|
2
|
-
require 'json'
|
3
|
-
require 'openssl'
|
4
|
-
require 'net/https'
|
5
|
-
require 'time'
|
6
|
-
require 'uri'
|
1
|
+
# frozen_string_literal: true
|
7
2
|
|
8
|
-
|
9
|
-
|
10
|
-
#
|
11
|
-
class DuoApi
|
12
|
-
attr_accessor :ca_file
|
3
|
+
require_relative 'duo_api/api_client'
|
4
|
+
require_relative 'duo_api/api_helpers'
|
13
5
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
end
|
19
|
-
|
20
|
-
# Constants for handling rate limit backoff
|
21
|
-
MAX_BACKOFF_WAIT_SECS = 32
|
22
|
-
INITIAL_BACKOFF_WAIT_SECS = 1
|
23
|
-
BACKOFF_FACTOR = 2
|
24
|
-
RATE_LIMITED_RESP_CODE = '429'
|
25
|
-
|
26
|
-
def initialize(ikey, skey, host, proxy = nil, ca_file: nil)
|
27
|
-
@ikey = ikey
|
28
|
-
@skey = skey
|
29
|
-
@host = host
|
30
|
-
if proxy.nil?
|
31
|
-
@proxy = []
|
32
|
-
else
|
33
|
-
proxy_uri = URI.parse proxy
|
34
|
-
@proxy = [
|
35
|
-
proxy_uri.host,
|
36
|
-
proxy_uri.port,
|
37
|
-
proxy_uri.user,
|
38
|
-
proxy_uri.password
|
39
|
-
]
|
40
|
-
end
|
41
|
-
@ca_file = ca_file ||
|
42
|
-
File.join(File.dirname(__FILE__), '..', 'ca_certs.pem')
|
43
|
-
end
|
44
|
-
|
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
|
-
|
54
|
-
uri = request_uri(path, params)
|
55
|
-
current_date, signed = sign(method, uri.host, path, params, body, additional_headers)
|
56
|
-
|
57
|
-
request = Net::HTTP.const_get(method.capitalize).new uri.to_s
|
58
|
-
request.basic_auth(@ikey, signed)
|
59
|
-
request['Date'] = current_date
|
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
|
65
|
-
|
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|
|
71
|
-
wait_secs = INITIAL_BACKOFF_WAIT_SECS
|
72
|
-
while true do
|
73
|
-
resp = http.request(request)
|
74
|
-
if resp.code != RATE_LIMITED_RESP_CODE or wait_secs > MAX_BACKOFF_WAIT_SECS
|
75
|
-
return resp
|
76
|
-
end
|
77
|
-
random_offset = rand()
|
78
|
-
sleep(wait_secs + random_offset)
|
79
|
-
wait_secs *= BACKOFF_FACTOR
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
private
|
85
|
-
|
86
|
-
def encode_key_val(k, v)
|
87
|
-
# encode the key and the value for a url
|
88
|
-
key = ERB::Util.url_encode(k.to_s)
|
89
|
-
value = ERB::Util.url_encode(v.to_s)
|
90
|
-
key + '=' + value
|
91
|
-
end
|
92
|
-
|
93
|
-
def canon_params(params_hash = nil)
|
94
|
-
return '' if params_hash.nil?
|
95
|
-
params_hash.sort.map do |k, v|
|
96
|
-
# when it is an array, we want to add that as another param
|
97
|
-
# eg. next_offset = ['1547486297000', '5bea1c1e-612c-4f1d-b310-75fd31385b15']
|
98
|
-
if v.is_a?(Array)
|
99
|
-
encode_key_val(k, v[0]) + '&' + encode_key_val(k, v[1])
|
100
|
-
else
|
101
|
-
encode_key_val(k, v)
|
102
|
-
end
|
103
|
-
end.join('&')
|
104
|
-
end
|
105
|
-
|
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)
|
137
|
-
end
|
138
|
-
|
139
|
-
def request_uri(path, params = nil)
|
140
|
-
u = 'https://' + @host + path
|
141
|
-
u += '?' + canon_params(params) unless params.nil?
|
142
|
-
URI.parse(u)
|
143
|
-
end
|
144
|
-
|
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()
|
148
|
-
canon = [
|
149
|
-
date,
|
150
|
-
method.upcase,
|
151
|
-
host.downcase,
|
152
|
-
path,
|
153
|
-
canon_params(params),
|
154
|
-
OpenSSL::Digest::SHA512.hexdigest(body),
|
155
|
-
canon_x_duo_headers(additional_headers)
|
156
|
-
]
|
157
|
-
[date, canon.join("\n")]
|
158
|
-
end
|
159
|
-
|
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)
|
163
|
-
[date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)]
|
164
|
-
end
|
165
|
-
end
|
6
|
+
require_relative 'duo_api/admin'
|
7
|
+
require_relative 'duo_api/accounts'
|
8
|
+
require_relative 'duo_api/auth'
|
9
|
+
require_relative 'duo_api/device'
|