182 lines
8.1 KiB
Python
Executable File
182 lines
8.1 KiB
Python
Executable File
#! /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?)')
|