duo_api.py
8.18 KB
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
#! /usr/bin/env python3
#
# duo_api.py - pure python3 implementation of an DUO API client
#
# Version 1.0, latest version, documentation and bugtracker available at:
# https://gitlab.lindenaar.net/scripts/duo
#
# Copyright (c) 2019 Frederik Lindenaar
#
# This script is free software: you can redistribute and/or modify it under the
# terms of version 3 of the GNU General Public License as published by the Free
# Software Foundation, or (at your option) any later version of the license.
#
# This script is distributed in the hope that it will be useful but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, visit <http://www.gnu.org/licenses/> to download it.
#
""" Python3 DUO API client implementation using standard libraries.
This python script implements the client side of the DUO API using only
python3 libaries installed by default (i.e. no DUO library) and can be used
without the need to install any other libraries and outside a virtualenv.
The script could be used as library itself though that would defeat the
whole purpose (apart from it being very simple/small) but it intended as
starting point / template to implement you own custom logic directly. It
contains a number of examples of the implemented direct and config-based
method to get you started.
To run the config-file implementation of the client, simply create a config
file (duo_api.conf) from the example (duo_api.conf.dist) and configure it
for your environment in the same directory as this script and run it. To use
it without config file, comment out the code below and provide your values.
See https://duo.com/docs/authapi and https://duo.com/docs/adminapi for the
available endpoints, parameters and responses.
"""
from base64 import b64encode
from configparser import ConfigParser
from email.utils import formatdate as timestamp
from hashlib import sha1 as SHA1
from hmac import new as hmac
from json import loads as parse_json
from os.path import splitext
from sys import argv
from urllib.error import HTTPError
from urllib.parse import quote as urlquote
from urllib.request import urlopen, Request
def urlencode(p):
""" URL-encodes HTTP paramaters provided in the passed dict / (k,v) list """
return '&'.join([ urlquote(k,'~') + '=' + urlquote(v,'~')
for (k, v) in (p.items() if isinstance(p, dict) else p if p else [] )])
def duo_api(host, ikey, skey, path, params=None, method=None, proto='https'):
""" Performs an authenticated DUO API request returning its JSON response
Parameters:
host: API Hostname (from DUO Admin application config)
ikey: API Integration Key (from DUO Admin application config)
skey: API Secret Key (from DUO Admin application config)
path: REST request path, e.g. '/auth/v2/check'
params: dict with unencoded request parameters, will be URL-encoded
method: http method, auto-detected (POST with params, GET otherwise)
proto: protocol, defaults to 'https'
Returns: the API response (JSON Object)
"""
ts, method = timestamp(), method if method else 'POST' if params else 'GET'
data = urlencode(sorted(params.items())) if params else ''
auth = '\n'.join([ts, method.upper(), host.lower(), path, data]).encode()
sig = (ikey + ':' + hmac(skey.encode(), auth, SHA1).hexdigest()).encode()
return parse_json(urlopen(Request(proto + '://' + host + path, headers={
'Date': ts, 'Authorization': 'Basic ' + b64encode(sig).decode()
}, method=method, data=data.encode())).read())['response']
def duo_api_config(config, req, vars=None):
""" Performs an authenticated DUO API request with duo_api() using a dict()
or ConfigParser Object containing at least the key (section) 'API'.
Parameters:
config: ConfigParser object (or dict) with at least an API section
section: name of the DUO API endpoint and config section to use
vars: (optional) dict with variables for substitution in values
Returns: the API response (JSON Object)
To pass parameters with the request, provide a dict (section) with a key
equal to req. Below a ConfigParser config file for an auth request:
[API]
host=api-XXXXXXXX.duosecurity.com
ikey=XXXXXXXXXXXXXXXXXXXX
skey=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
path=/auth/v2/
[auth]
username=username
factor=push
device=auto
type=Network Access
display_username=Test User
pushinfo=explanation=Text+section(s)+shown+to+the+user&mode=TEST
Sections correspond with the API endpoint / request in req. For multiple
instances of the same requests, append a suffix to the section name with
an underscore ('_') as everything after that will be ignored as request
name. Within the section keywords correspond to the request parameters.
In case no parameters are required (e.g. for 'check' only API suffices).
When using a ConfigParser object, variable substitution is supported for
with the parameters passed in vars to make things dynamic.
"""
apiconf = config['API']
path = (apiconf['path'] + '/' + req.split('_')[0]).replace('//', '/') \
if 'path' in apiconf and req[0] != '/' else req.split('_')[0]
params = config.get(req, None) if not isinstance(config, ConfigParser) \
else dict(config.items(req, vars=vars)) if req in config else None
return duo_api(apiconf['host'],apiconf['ikey'],apiconf['skey'],path,params)
# Main logic to execute is case this is the called script
if __name__ == '__main__':
try:
# Example using duo_api() directly, make sure you set parameters below!
# host='api-XXXXXXXX.duosecurity.com'
# ikey='XXXXXXXXXXXXXXXXXXXX'
# skey='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
# response = duo_api(host, ikey, skey, '/auth/v2/check')
# print(response)
# More complex example, request DUO token authorization with parameters
# params = { 'username': 'username',
# 'factor': 'push',
# 'device': 'auto',
# 'type': 'Network Access',
# 'display_username': 'Test User',
# 'pushinfo': urlencode({
# 'explanation': 'Text section(s) shown to the user',
# 'mode': 'TEST'
# })
# }
# response = duo_api(host, ikey, skey, '/auth/v2/auth', params)
# print(response)
# print('Access', response['result'])
# Same example as above, now using a config file, for this to work copy
# duo_api.conf.dist to duo_api.conf and update for your environment
config = ConfigParser()
if not config.read(splitext(argv[0])[0] + '.conf'):
print("Missing/unreadable config file",splitext(argv[0])[0]+'.conf')
exit(1)
apiconf = config['API']
print(apiconf.get('aaa', fallback='bbb'))
exit()
response = duo_api_config(config, 'check') # Check connectivity/auth.
print(response)
response = duo_api_config(config, 'auth') # trigger DUO authorization
print(response)
print('Access', response['result'])
# Example of a second authorization definition with dynamic parameters
context = { # Populate context from the command line (if provided)
'login': argv[1] if len(argv) > 1 else 'username',
'name': argv[2] if len(argv) > 2 else 'Dynamic User',
'banner': argv[3] if len(argv) > 3 else 'Access Request'
}
response = duo_api_config(config, 'auth_dynamic', vars=context)
print(response)
print('Access', response['result'])
except HTTPError as e:
print('Configuration Error', e, '(invalid parameters?)')