duo_api.py 8.09 KB
#! /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)

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