1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
|
# frozen_string_literal: true
require 'base64'
require 'erb'
require 'json'
require 'openssl'
require 'net/https'
require 'time'
require 'uri'
##
# A Ruby implementation of the Duo API
#
class DuoApi
attr_accessor :ca_file
attr_reader :default_params
VERSION = Gem.loaded_specs['duo_api'] ? Gem.loaded_specs['duo_api'].version : '0.0.0'
# Constants for handling rate limit backoff
MAX_BACKOFF_WAIT_SECS = 32
INITIAL_BACKOFF_WAIT_SECS = 1
BACKOFF_FACTOR = 2
def initialize(ikey, skey, host, proxy = nil, ca_file: nil, default_params: {})
@ikey = ikey
@skey = skey
@host = host
@proxy_str = proxy
if proxy.nil?
@proxy = []
else
proxy_uri = URI.parse proxy
@proxy = [
proxy_uri.host,
proxy_uri.port,
proxy_uri.user,
proxy_uri.password
]
end
@ca_file = ca_file ||
File.join(File.dirname(__FILE__), '..', '..', 'ca_certs.pem')
@default_params = default_params.transform_keys(&:to_sym)
end
def default_params=(default_params)
@default_params = default_params.transform_keys(&:to_sym)
end
# Basic authenticated request returning raw Net::HTTPResponse object
def request(method, path, params = {}, additional_headers = nil)
# Merge default params with provided params
params = @default_params.merge(params.transform_keys(&:to_sym))
# Determine if params should be in a JSON request body
params_go_in_body = %w[POST PUT PATCH].include?(method)
if params_go_in_body
body = canon_json(params)
params = {}
else
body = ''
end
# Construct the request URI
uri = request_uri(path, params)
# Sign the request
current_date, signed = sign(method, uri.host, path, params, body, additional_headers)
# Create the HTTP request object
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/#{VERSION}"
# Set Content-Type and request body for JSON requests
if params_go_in_body
request['Content-Type'] = 'application/json'
request.body = body
end
# Start the HTTP session
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
loop do
resp = http.request(request)
# Check if the response is rate-limited and handle backoff
return resp if !resp.is_a?(Net::HTTPTooManyRequests) || (wait_secs > MAX_BACKOFF_WAIT_SECS)
random_offset = rand
sleep(wait_secs + random_offset)
wait_secs *= BACKOFF_FACTOR
end
end
end
private
# Encode a key-value pair for a URL
def encode_key_val(key, val)
key = ERB::Util.url_encode(key.to_s)
value = ERB::Util.url_encode(val.to_s)
"#{key}=#{value}"
end
# Build a canonical parameter string
def canon_params(params_hash = nil)
return '' if params_hash.nil?
params_hash.transform_keys(&:to_s).sort.map do |k, v|
# When value an array, repeat key for each unique value in sorted array
if v.is_a?(Array)
if v.count.positive?
v.sort.uniq.map{ |vn| encode_key_val(k, vn) }.join('&')
else
encode_key_val(k, '')
end
else
encode_key_val(k, v)
end
end.join('&')
end
# Generate a canonical JSON body
def canon_json(params_hash = nil)
return '' if params_hash.nil?
JSON.generate(params_hash.sort.to_h)
end
# Canonicalize additional headers for signing
def canon_x_duo_headers(additional_headers)
additional_headers ||= {}
unless additional_headers.none?{ |k, v| k.nil? || v.nil? }
raise(HeaderError, '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
# Validate additional headers to ensure they meet requirements
def validate_additional_header(header_name, value, added_headers)
header_name.downcase!
raise(HeaderError, 'Not allowed "Null" character in header name') if header_name.include?("\x00")
raise(HeaderError, 'Not allowed "Null" character in header value') if value.include?("\x00")
raise(HeaderError, 'Additional headers must start with \'X-Duo-\'') unless header_name.start_with?('x-duo-')
raise(HeaderError, "Duplicate header passed, header=#{header_name}") if added_headers.include?(header_name)
end
# Construct the request URI
def request_uri(path, params = nil)
u = "https://#{@host}#{path}"
u += "?#{canon_params(params)}" unless params.nil?
URI.parse(u)
end
# Create a canonical string for signing requests
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 = [
date,
method.upcase,
host.downcase,
path,
canon_params(params),
OpenSSL::Digest::SHA512.hexdigest(body),
canon_x_duo_headers(additional_headers)
]
[date, canon.join("\n")]
end
# Sign the request with HMAC-SHA512
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
# Custom Error Classes
class HeaderError < StandardError; end
class RateLimitError < StandardError; end
class ResponseCodeError < StandardError; end
class ContentTypeError < StandardError; end
class PaginationError < StandardError; end
class ChildAccountError < StandardError; end
end
|