Blame view

duo_api.py 8.09 KB
Frederik Lindenaar authored
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
#! /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
Frederik Lindenaar authored
36
37
38
    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.
Frederik Lindenaar authored
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

    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
Frederik Lindenaar authored
99
        equal to req. Below a ConfigParser config file for an auth request:
Frederik Lindenaar authored
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

            [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
Frederik Lindenaar authored
157
        # duo_api.conf.dist to duo_api.conf and update for your environment
Frederik Lindenaar authored
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
        config = ConfigParser()
        if not config.read(splitext(argv[0])[0] + '.conf'):
            print("Missing/unreadable config file",splitext(argv[0])[0]+'.conf')
            exit(1)

        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?)')