#! /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?)')