Skip to content

Commit eb25127

Browse files
authored
Merge pull request #200 from jpringle03/master
Resubmit "Add sig_v5 support to duo_client_python calls and v2 integrations handler"
2 parents d08126e + 0cb1ffc commit eb25127

12 files changed

Lines changed: 426 additions & 121 deletions

duo_client/admin.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,18 @@
216216

217217
class Admin(client.Client):
218218
account_id = None
219+
sig_version = 5
219220

220221
def api_call(self, method, path, params):
221222
if self.account_id is not None:
222223
params['account_id'] = self.account_id
223-
return super(Admin, self).api_call(method, path, params)
224+
225+
return super(Admin, self).api_call(
226+
method,
227+
path,
228+
params,
229+
)
230+
224231

225232
@classmethod
226233
def _canonicalize_ip_whitelist(klass, ip_whitelist):
@@ -2378,8 +2385,8 @@ def get_integrations_generator(self):
23782385
"""
23792386
return self.json_paging_api_call(
23802387
'GET',
2381-
'/admin/v1/integrations',
2382-
{}
2388+
'/admin/v2/integrations',
2389+
{},
23832390
)
23842391

23852392
def get_integrations(self, limit=None, offset=0):
@@ -2398,8 +2405,8 @@ def get_integrations(self, limit=None, offset=0):
23982405
if limit:
23992406
return self.json_api_call(
24002407
'GET',
2401-
'/admin/v1/integrations',
2402-
{'limit': limit, 'offset': offset}
2408+
'/admin/v2/integrations',
2409+
{'limit': limit, 'offset': offset},
24032410
)
24042411

24052412
return list(self.get_integrations_generator())
@@ -2417,8 +2424,8 @@ def get_integration(self, integration_key):
24172424
params = {}
24182425
response = self.json_api_call(
24192426
'GET',
2420-
'/admin/v1/integrations/' + integration_key,
2421-
params
2427+
'/admin/v2/integrations/' + integration_key,
2428+
params,
24222429
)
24232430
return response
24242431

@@ -2441,7 +2448,8 @@ def create_integration(self,
24412448
ip_whitelist=None,
24422449
ip_whitelist_enroll_policy=None,
24432450
groups_allowed=None,
2444-
self_service_allowed=None):
2451+
self_service_allowed=None,
2452+
sso=None):
24452453
"""Creates a new integration.
24462454
24472455
name - The name of the integration (required)
@@ -2467,6 +2475,9 @@ def create_integration(self,
24672475
adminapi_write_resource - <bool:write resource permission>|None
24682476
groups_allowed - <str: CSV list of gkeys of groups allowed to auth>
24692477
self_service_allowed - <bool: self service permission>|None
2478+
sso - <dict: parameters for generic single sign-on> (optional)
2479+
New argument for unreleased feature. Will return an error if used.
2480+
Client will be updated again in the future when feature is released.
24702481
24712482
Returns the created integration.
24722483
@@ -2514,9 +2525,12 @@ def create_integration(self,
25142525
params['groups_allowed'] = groups_allowed
25152526
if self_service_allowed is not None:
25162527
params['self_service_allowed'] = '1' if self_service_allowed else '0'
2528+
if sso is not None:
2529+
params['sso'] = sso
25172530
response = self.json_api_call('POST',
2518-
'/admin/v1/integrations',
2519-
params)
2531+
'/admin/v2/integrations',
2532+
params,
2533+
)
25202534
return response
25212535

25222536
def delete_integration(self, integration_key):
@@ -2528,8 +2542,12 @@ def delete_integration(self, integration_key):
25282542
25292543
"""
25302544
integration_key = six.moves.urllib.parse.quote_plus(str(integration_key))
2531-
path = '/admin/v1/integrations/%s' % integration_key
2532-
return self.json_api_call('DELETE', path, {})
2545+
path = '/admin/v2/integrations/%s' % integration_key
2546+
return self.json_api_call(
2547+
'DELETE',
2548+
path,
2549+
{},
2550+
)
25332551

25342552
def update_integration(self,
25352553
integration_key,
@@ -2551,7 +2569,8 @@ def update_integration(self,
25512569
ip_whitelist=None,
25522570
ip_whitelist_enroll_policy=None,
25532571
groups_allowed=None,
2554-
self_service_allowed=None):
2572+
self_service_allowed=None,
2573+
sso=None):
25552574
"""Updates an integration.
25562575
25572576
integration_key - The key of the integration to update. (required)
@@ -2576,6 +2595,9 @@ def update_integration(self,
25762595
reset_secret_key - <any value>|None
25772596
groups_allowed - <str: CSV list of gkeys of groups allowed to auth>
25782597
self_service_allowed - True|False|None
2598+
sso - <dict: parameters for generic single sign-on> (optional)
2599+
New argument for unreleased feature. Will return an error if used.
2600+
Client will be updated again in the future when feature is released.
25792601
25802602
If any value other than None is provided for 'reset_secret_key'
25812603
(for example, 1), then a new secret key will be generated for the
@@ -2587,7 +2609,7 @@ def update_integration(self,
25872609
25882610
"""
25892611
integration_key = six.moves.urllib.parse.quote_plus(str(integration_key))
2590-
path = '/admin/v1/integrations/%s' % integration_key
2612+
path = '/admin/v2/integrations/%s' % integration_key
25912613
params = {}
25922614
if name is not None:
25932615
params['name'] = name
@@ -2629,11 +2651,17 @@ def update_integration(self,
26292651
params['groups_allowed'] = groups_allowed
26302652
if self_service_allowed is not None:
26312653
params['self_service_allowed'] = '1' if self_service_allowed else '0'
2654+
if sso is not None:
2655+
params['sso'] = sso
26322656

26332657
if not params:
26342658
raise TypeError("No new values were provided")
26352659

2636-
response = self.json_api_call('POST', path, params)
2660+
response = self.json_api_call(
2661+
'POST',
2662+
path,
2663+
params,
2664+
)
26372665
return response
26382666

26392667
def get_admins(self, limit=None, offset=0):

duo_client/client.py

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,63 @@ def canon_params(params):
5454
return '&'.join(args)
5555

5656

57-
def canonicalize(method, host, uri, params, date, sig_version, body=None):
57+
def canon_x_duo_headers(additional_headers):
58+
"""
59+
Args:
60+
additional_headers: Dict
61+
Returns:
62+
stringified version of all headers that start with 'X-Duo*'. Which is then hashed.
63+
Note: the keys are also lower-cased for signing.
64+
"""
65+
if additional_headers is None:
66+
additional_headers = {}
67+
68+
# Lower the headers before sorting them
69+
lowered_headers = {}
70+
for header_name, header_value in additional_headers.items():
71+
header_name = header_name.lower() if header_name is not None else None
72+
lowered_headers[header_name] = header_value
73+
74+
canon_list = []
75+
added_headers = [] # store headers we've added, use for duplicate checking (case insensitive)
76+
for header_name in sorted(lowered_headers.keys()):
77+
# Extract header value and set key to lower case from now on.
78+
value = lowered_headers[header_name]
79+
80+
# Validation gate. We will raise if a problem is found here.
81+
_validate_additional_header(header_name, value, added_headers)
82+
83+
# Add to the list of values to canonicalize:
84+
canon_list.extend([header_name, value])
85+
added_headers.append(header_name)
86+
87+
canon = '\x00'.join(canon_list)
88+
return hashlib.sha512(canon.encode('utf-8')).hexdigest()
89+
90+
91+
def _validate_additional_header(header_name, value, added_headers):
92+
"""
93+
Args:
94+
header_name: str
95+
value: str
96+
added_headers: list[str] - headers we've already added - check for duplicates (case insensitive)
97+
Returns: None
98+
99+
Validates additional headers added to request - headers must comply with the following rules (for V5 sig_version)
100+
"""
101+
if header_name is None or value is None:
102+
raise ValueError("Not allowed 'None' as a header name or value")
103+
if '\x00' in header_name:
104+
raise ValueError("Not allowed 'Null' character in header name")
105+
if '\x00' in value:
106+
raise ValueError("Not allowed 'Null' character in header value")
107+
if not header_name.lower().startswith('x-duo-'):
108+
raise ValueError("Additional headers must start with \'X-Duo-\'")
109+
if header_name.lower() in added_headers:
110+
raise ValueError("Duplicate header passed, header={}".format(header_name))
111+
112+
113+
def canonicalize(method, host, uri, params, date, sig_version, body=None, additional_headers=None):
58114
"""
59115
Return a canonical string version of the given request attributes.
60116
@@ -91,17 +147,27 @@ def canonicalize(method, host, uri, params, date, sig_version, body=None):
91147
canon_params(params),
92148
hashlib.sha512(body.encode('utf-8')).hexdigest(),
93149
]
150+
elif sig_version == 5:
151+
canon = [
152+
date,
153+
method.upper(),
154+
host.lower(),
155+
uri,
156+
canon_params(params),
157+
hashlib.sha512(body.encode('utf-8')).hexdigest(),
158+
canon_x_duo_headers(additional_headers), # hashed in canon_x_duo_headers
159+
]
94160
else:
95161
raise ValueError("Unknown signature version: {}".format(sig_version))
96162
return '\n'.join(canon)
97163

98164

99165
def sign(ikey, skey, method, host, uri, date, sig_version, params, body=None,
100-
digestmod=hashlib.sha512):
166+
digestmod=hashlib.sha512, additional_headers=None):
101167
"""
102168
Return basic authorization header line with a Duo Web API signature.
103169
"""
104-
canonical = canonicalize(method, host, uri, params, date, sig_version, body=body)
170+
canonical = canonicalize(method, host, uri, params, date, sig_version, body=body, additional_headers=additional_headers)
105171
if isinstance(skey, six.text_type):
106172
skey = skey.encode('utf-8')
107173
if isinstance(canonical, six.text_type):
@@ -146,15 +212,16 @@ def to_list(value):
146212

147213

148214
class Client(object):
215+
sig_version = 2
149216

150217
def __init__(self, ikey, skey, host,
151218
ca_certs=DEFAULT_CA_CERTS,
152219
sig_timezone='UTC',
153220
user_agent=('Duo API Python/' + __version__),
154221
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
155222
paging_limit=100,
156-
digestmod=hashlib.sha512,
157-
sig_version=2,
223+
digestmod=hashlib.sha512,
224+
sig_version=None,
158225
port=None
159226
):
160227
"""
@@ -172,7 +239,8 @@ def __init__(self, ikey, skey, host,
172239
self.set_proxy(host=None, proxy_type=None)
173240
self.paging_limit = paging_limit
174241
self.digestmod = digestmod
175-
self.sig_version = sig_version
242+
if sig_version is not None:
243+
self.sig_version = sig_version
176244

177245
# Constants for handling rate limit backoff and retries
178246
self._MAX_BACKOFF_WAIT_SECS = 32
@@ -189,9 +257,6 @@ def __init__(self, ikey, skey, host,
189257
if sig_version == 3:
190258
raise ValueError('sig_version 3 not supported')
191259

192-
if sig_version == 4 and digestmod != hashlib.sha512:
193-
raise ValueError('sha512 required for sig_version 4')
194-
195260
def set_proxy(self, host, port=None, headers=None,
196261
proxy_type='CONNECT'):
197262
"""
@@ -207,28 +272,45 @@ def set_proxy(self, host, port=None, headers=None,
207272
self.proxy_port = port
208273
self.proxy_type = proxy_type
209274

210-
def api_call(self, method, path, params):
275+
def api_call(
276+
self,
277+
method,
278+
path,
279+
params,
280+
additional_headers=None,
281+
sig_version=None,
282+
):
211283
"""
212284
Call a Duo API method. Return a (response, data) tuple.
213285
214286
* method: HTTP request method. E.g. "GET", "POST", or "DELETE".
215287
* path: Full path of the API endpoint. E.g. "/auth/v2/ping".
216288
* params: dict mapping from parameter name to stringified value,
217289
or a dict to be converted to json.
290+
* sig_version: signature version integer
218291
"""
219292
params_go_in_body = method in ('POST', 'PUT', 'PATCH')
220-
if self.sig_version in (1, 2):
293+
digestmod = self.digestmod
294+
if additional_headers is None:
295+
additional_headers = {}
296+
if sig_version is None:
297+
sig_version = self.sig_version
298+
299+
if sig_version in (1, 2):
221300
params = normalize_params(params)
222301
# v1 and v2 canonicalization don't distinguish between
223302
# params and body. There's no separate body input.
224303
body = None
225-
elif self.sig_version == 4:
304+
elif sig_version in (4, 5):
305+
digestmod = hashlib.sha512
226306
if params_go_in_body:
227307
body = self.canon_json(params)
228308
params = {}
229309
else:
230310
body = ''
231311
params = normalize_params(params)
312+
else:
313+
raise ValueError(f"unsupported sig_version {sig_version}")
232314

233315
if self.sig_timezone == 'UTC':
234316
now = email.utils.formatdate()
@@ -244,20 +326,25 @@ def api_call(self, method, path, params):
244326
self.host,
245327
path,
246328
now,
247-
self.sig_version,
329+
sig_version,
248330
params,
249331
body=body,
250-
digestmod=self.digestmod)
332+
digestmod=digestmod,
333+
additional_headers=additional_headers)
251334
headers = {
252335
'Authorization': auth,
253336
'Date': now,
254337
}
255338

339+
if sig_version == 5:
340+
for k, v in additional_headers.items():
341+
headers[k] = v
342+
256343
if self.user_agent:
257344
headers['User-Agent'] = self.user_agent
258345

259346
if params_go_in_body:
260-
if self.sig_version == 4:
347+
if sig_version in (4, 5):
261348
headers['Content-type'] = 'application/json'
262349
else:
263350
headers['Content-type'] = 'application/x-www-form-urlencoded'

0 commit comments

Comments
 (0)