├── .flake8 ├── .github ├── actions │ └── sbom-convert │ │ └── action.yml └── workflows │ ├── publish.yml │ └── python-ci.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── apache-license-2.0.txt ├── duo_client ├── __init__.py ├── accounts.py ├── admin.py ├── auth.py ├── auth_v1.py ├── ca_certs.pem ├── client.py ├── https_wrapper.py ├── logs │ ├── __init__.py │ └── telephony.py └── util.py ├── examples ├── Accounts │ ├── README.md │ ├── create_child_account.py │ ├── create_integration_in_child_account.py │ ├── delete_child_account.py │ ├── get_account_edition.py │ ├── get_billing_and_telephony_credits.py │ ├── retrieve_account_list.py │ ├── retrieve_integrations_from_child_account.py │ └── set_account_edition.py ├── Admin │ ├── README.md │ ├── create_admin.py │ ├── create_integration_sso_generic.py │ ├── create_user_and_phone.py │ ├── get_users_in_group_with_aliases.py │ ├── log_examples.py │ ├── policies.py │ ├── policies_advanced.py │ ├── report_auths_by_country.py │ ├── report_user_activity.py │ ├── report_user_by_email.py │ ├── report_users_and_phones.py │ ├── trust_monitor_events.py │ ├── update_admin.py │ └── update_phone_names.py ├── Auth │ ├── README.md │ ├── async_advanced_user_mfa.log │ ├── async_advanced_user_mfa.py │ ├── async_basic_user_mfa.py │ ├── basic_user_mfa.py │ └── basic_user_mfa_token.py ├── README.md └── splunk │ ├── duo.conf │ └── splunk.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── accountAdmin ├── __init__.py ├── base.py └── test_billing.py ├── admin ├── __init__.py ├── base.py ├── test_activity.py ├── test_administrative_units.py ├── test_admins.py ├── test_authlog.py ├── test_bypass_codes.py ├── test_desktop_tokens.py ├── test_endpoints.py ├── test_groups.py ├── test_integration.py ├── test_integrations.py ├── test_logo.py ├── test_passport.py ├── test_phones.py ├── test_policies.py ├── test_registered_devices.py ├── test_settings.py ├── test_telephony.py ├── test_tokens.py ├── test_trust_monitor_events.py ├── test_u2f.py ├── test_user_bypass_codes.py ├── test_user_groups.py ├── test_user_phones.py ├── test_user_tokens.py ├── test_user_u2f.py ├── test_user_webauthn.py ├── test_users.py ├── test_verification_push.py └── test_webauthn.py ├── resources └── barn-owl-small.png ├── test_client.py ├── test_https_wrapper.py └── util.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = DUO 3 | exclude = duo_client/https_wrapper.py 4 | -------------------------------------------------------------------------------- /.github/actions/sbom-convert/action.yml: -------------------------------------------------------------------------------- 1 | name: Action for converting CycloneDX SBOM files to SPDX format 2 | runs: 3 | using: "composite" 4 | steps: 5 | - name: Install CycloneDX 6 | run: | 7 | wget https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.24.2/cyclonedx-linux-x64 8 | chmod a+x cyclonedx-linux-x64 9 | shell: bash 10 | - name: Convert SBOM 11 | run: | 12 | ./cyclonedx-linux-x64 convert --input-format json --output-format spdxjson --input-file cyclonedx-sbom.json --output-file spdx.json 13 | shell: bash -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Generate SBOM 26 | run: | 27 | pip install cyclonedx-bom==3.11.7 28 | cyclonedx-py --e --format json -o cyclonedx-sbom.json 29 | - name: Convert SBOM 30 | uses: duosecurity/duo_client_python/.github/actions/sbom-convert@master 31 | - name: Build and publish 32 | env: 33 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 34 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 35 | run: | 36 | python setup.py sdist bdist_wheel 37 | twine upload dist/* 38 | -------------------------------------------------------------------------------- /.github/workflows/python-ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | name: Python CI - test 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Dependencies 28 | run: | 29 | pip install -r requirements.txt 30 | pip install -r requirements-dev.txt 31 | - name: Lint with flake8 32 | run: flake8 33 | - name: Test with nose2 34 | run: nose2 35 | 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.swp 4 | *~ 5 | .gitconfig 6 | MANIFEST 7 | build 8 | dist 9 | .idea 10 | env/ 11 | py3env/ 12 | .venv 13 | duo_client.egg-info 14 | *.DS_Store 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 3. The name of the author may not be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 17 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 25 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | 28 | Note: The open-source component https_wrapper.py included with this 29 | distribution is under the terms of the Apache License, Version 2.0, a 30 | copy of which has been included as 'apache-license-2.0.txt'. 31 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include apache-license-2.0.txt 2 | include LICENSE 3 | recursive-include examples * 4 | include tests/*.py 5 | include tests/admin/*.py 6 | include README.md 7 | include duo_client/ca_certs.pem 8 | include requirements.txt 9 | include requirements-dev.txt 10 | include spdx.json 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | [![Build Status](https://github.com/duosecurity/duo_client_python/workflows/Python%20CI/badge.svg)](https://github.com/duosecurity/duo_client_python/actions) 4 | [![Issues](https://img.shields.io/github/issues/duosecurity/duo_client_python)](https://github.com/duosecurity/duo_client_python/issues) 5 | [![Forks](https://img.shields.io/github/forks/duosecurity/duo_client_python)](https://github.com/duosecurity/duo_client_python/network/members) 6 | [![Stars](https://img.shields.io/github/stars/duosecurity/duo_client_python)](https://github.com/duosecurity/duo_client_python/stargazers) 7 | [![License](https://img.shields.io/badge/License-View%20License-orange)](https://github.com/duosecurity/duo_client_python/blob/master/LICENSE) 8 | 9 | **Auth** - https://www.duosecurity.com/docs/authapi 10 | 11 | **Admin** - https://www.duosecurity.com/docs/adminapi 12 | 13 | **Accounts** - https://www.duosecurity.com/docs/accountsapi 14 | 15 | **Activity** - The activity endpoint is in public preview and subject to change 16 | 17 | ## Tested Against Python Versions 18 | * 3.7 19 | * 3.8 20 | * 3.9 21 | * 3.10 22 | * 3.11 23 | 24 | ## Requirements 25 | Duo_client_python supports Python 3.7 and higher 26 | 27 | ## TLS 1.2 and 1.3 Support 28 | 29 | Duo_client_python uses Python's ssl module and OpenSSL for TLS operations. Python versions 3.7 (and higher) have both TLS 1.2 and TLS 1.3 support. 30 | 31 | # Installing 32 | 33 | Development: 34 | 35 | ``` 36 | $ git clone https://github.com/duosecurity/duo_client_python.git 37 | $ cd duo_client_python 38 | $ virtualenv .env 39 | $ source .env/bin/activate 40 | $ pip install --requirement requirements.txt 41 | $ pip install --requirement requirements-dev.txt 42 | $ python setup.py install 43 | ``` 44 | 45 | System: 46 | 47 | Install from [PyPi](https://pypi.org/project/duo-client/) 48 | ``` 49 | $ pip install duo-client 50 | ``` 51 | 52 | # Using 53 | 54 | See the `examples` folder for how to use this library. 55 | 56 | To run an example query, execute a command like the following from the repo root: 57 | ``` 58 | $ python examples/Admin/report_users_and_phones.py 59 | ``` 60 | 61 | # Testing 62 | 63 | ``` 64 | $ nose2 65 | 66 | Example: `cd tests/admin && nose2` 67 | ``` 68 | 69 | # Linting 70 | 71 | ``` 72 | $ flake8 73 | ``` 74 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Duo is committed to providing secure software to all our customers and users. We take all security concerns seriously and ask that any disclosures be handled responsibly. 2 | 3 | # Security Policy 4 | 5 | ## Reporting a Vulnerability 6 | **Please do not use Github issues or pull requests to report security vulnerabilities.** 7 | 8 | If you believe you have found a security vulnerability in Duo software, please follow our response process described at https://duo.com/support/security-and-reliability/security-response. 9 | -------------------------------------------------------------------------------- /duo_client/__init__.py: -------------------------------------------------------------------------------- 1 | from .accounts import Accounts 2 | from .admin import Admin 3 | from .auth import Auth 4 | from .client import __version__ 5 | 6 | __all__ = [ 7 | 'Accounts', 8 | 'Admin', 9 | 'Auth', 10 | ] 11 | -------------------------------------------------------------------------------- /duo_client/accounts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Duo Security Accounts API reference client implementation. 3 | 4 | 5 | """ 6 | from . import client 7 | 8 | class Accounts(client.Client): 9 | child_map = {} 10 | 11 | def get_child_accounts(self): 12 | """ 13 | Return a list of all child accounts of the integration's account. 14 | """ 15 | params = {} 16 | response = self.json_api_call('POST', 17 | '/accounts/v1/account/list', 18 | params) 19 | if response and isinstance(response, list): 20 | for account in response: 21 | account_id = account.get('account_id', None) 22 | api_hostname = account.get('api_hostname', None) 23 | if account_id and api_hostname: 24 | Accounts.child_map[account_id] = api_hostname 25 | return response 26 | 27 | def create_account(self, name): 28 | """ 29 | Create a new child account of the integration's account. 30 | """ 31 | params = { 32 | 'name': name, 33 | } 34 | response = self.json_api_call('POST', 35 | '/accounts/v1/account/create', 36 | params) 37 | return response 38 | 39 | def delete_account(self, account_id): 40 | """ 41 | Delete a child account of the integration's account. 42 | """ 43 | params = { 44 | 'account_id': account_id, 45 | } 46 | response = self.json_api_call('POST', 47 | '/accounts/v1/account/delete', 48 | params) 49 | return response 50 | -------------------------------------------------------------------------------- /duo_client/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Duo Security Auth API reference client implementation. 3 | 4 | 5 | """ 6 | from . import client 7 | 8 | class Auth(client.Client): 9 | def ping(self): 10 | """ 11 | Determine if the Duo service is up and responding. 12 | 13 | Returns information about the Duo service state: { 14 | 'time': , 15 | } 16 | """ 17 | return self.json_api_call('GET', '/auth/v2/ping', {}) 18 | 19 | def check(self): 20 | """ 21 | Determine if the integration key, secret key, and signature 22 | generation are valid. 23 | 24 | Returns information about the Duo service state: { 25 | 'time': , 26 | } 27 | """ 28 | return self.json_api_call('GET', '/auth/v2/check', {}) 29 | 30 | def logo(self): 31 | """ 32 | Retrieve the user-supplied logo. 33 | 34 | Returns the logo on success, raises RuntimeError on failure. 35 | """ 36 | response, data = self.api_call('GET', '/auth/v2/logo', {}) 37 | content_type = response.getheader('Content-Type') 38 | if content_type and content_type.startswith('image/'): 39 | return data 40 | else: 41 | return self.parse_json_response(response, data) 42 | 43 | def enroll(self, username=None, valid_secs=None, bypass_codes=None): 44 | """ 45 | Create a new user and associated numberless phone. 46 | 47 | Returns activation information: { 48 | 'activation_barcode': , 49 | 'activation_code': , 50 | 'bypass_codes': , 51 | 'user_id': , 52 | 'username': , 53 | 'valid_secs': , 54 | } 55 | """ 56 | params = {} 57 | if username is not None: 58 | params['username'] = username 59 | if valid_secs is not None: 60 | valid_secs = str(int(valid_secs)) 61 | params['valid_secs'] = valid_secs 62 | if bypass_codes is not None: 63 | bypass_codes = str(int(bypass_codes)) 64 | params['bypass_codes'] = bypass_codes 65 | return self.json_api_call('POST', 66 | '/auth/v2/enroll', 67 | params) 68 | 69 | def enroll_status(self, user_id, activation_code): 70 | """ 71 | Check if a user has been enrolled yet. 72 | 73 | Returns a string constant indicating whether the user has been 74 | enrolled or the code remains unclaimed. 75 | """ 76 | params = { 77 | 'user_id': user_id, 78 | 'activation_code': activation_code, 79 | } 80 | response = self.json_api_call('POST', 81 | '/auth/v2/enroll_status', 82 | params) 83 | return response 84 | 85 | def preauth(self, 86 | username=None, 87 | user_id=None, 88 | ipaddr=None, 89 | client_supports_verified_push=None, 90 | trusted_device_token=None): 91 | """ 92 | Determine if and with what factors a user may authenticate or enroll. 93 | 94 | See the adminapi docs for parameter and response information. 95 | """ 96 | params = {} 97 | if username is not None: 98 | params['username'] = username 99 | if user_id is not None: 100 | params['user_id'] = user_id 101 | if ipaddr is not None: 102 | params['ipaddr'] = ipaddr 103 | if client_supports_verified_push is not None: 104 | params['client_supports_verified_push'] = client_supports_verified_push 105 | if trusted_device_token is not None: 106 | params['trusted_device_token'] = trusted_device_token 107 | response = self.json_api_call('POST', 108 | '/auth/v2/preauth', 109 | params) 110 | return response 111 | 112 | def auth(self, 113 | factor, 114 | username=None, 115 | user_id=None, 116 | ipaddr=None, 117 | async_txn=False, 118 | type=None, 119 | display_username=None, 120 | pushinfo=None, 121 | device=None, 122 | passcode=None, 123 | txid=None): 124 | """ 125 | Perform second-factor authentication for a user. 126 | 127 | If async_txn is True, returns: { 128 | 'txid': , 129 | } 130 | 131 | Otherwise, returns: { 132 | 'result': , 133 | 'status': , 134 | 'status_msg': , 135 | } 136 | 137 | If Trusted Devices is enabled, async_txn is not True, and status is 138 | 'allow', another item is returned: 139 | 140 | * trusted_device_token: 141 | """ 142 | params = { 143 | 'factor': factor, 144 | 'async': str(int(async_txn)), 145 | } 146 | if username is not None: 147 | params['username'] = username 148 | if user_id is not None: 149 | params['user_id'] = user_id 150 | if ipaddr is not None: 151 | params['ipaddr'] = ipaddr 152 | if type is not None: 153 | params['type'] = type 154 | if display_username is not None: 155 | params['display_username'] = display_username 156 | if pushinfo is not None: 157 | params['pushinfo'] = pushinfo 158 | if device is not None: 159 | params['device'] = device 160 | if passcode is not None: 161 | params['passcode'] = passcode 162 | if txid is not None: 163 | params['txid'] = txid 164 | response = self.json_api_call('POST', 165 | '/auth/v2/auth', 166 | params) 167 | return response 168 | 169 | def auth_status(self, txid): 170 | """ 171 | Longpoll for the status of an asynchronous authentication call. 172 | 173 | Returns a dict with four items: 174 | 175 | * waiting: True if the authentication attempt is still in progress 176 | and the caller can continue to poll, else False. 177 | 178 | * success: True if the authentication request has completed and 179 | was a success, else False. 180 | 181 | * status: String constant identifying the request's state. 182 | 183 | * status_msg: Human-readable string describing the request state. 184 | 185 | If Trusted Devices is enabled, another item is returned when success 186 | is True: 187 | 188 | * trusted_device_token: String token to bypass second-factor 189 | authentication for this user during an admin-defined period. 190 | """ 191 | params = { 192 | 'txid': txid, 193 | } 194 | status = self.json_api_call('GET', 195 | '/auth/v2/auth_status', 196 | params) 197 | response = { 198 | 'waiting': (status.get('result') == 'waiting'), 199 | 'success': (status.get('result') == 'allow'), 200 | 'status': status.get('status', ''), 201 | 'status_msg': status.get('status_msg', ''), 202 | } 203 | 204 | if 'trusted_device_token' in status: 205 | response['trusted_device_token'] = status['trusted_device_token'] 206 | 207 | return response 208 | -------------------------------------------------------------------------------- /duo_client/auth_v1.py: -------------------------------------------------------------------------------- 1 | """ 2 | Duo Security Auth API v1 reference client implementation. 3 | 4 | 5 | """ 6 | from . import client 7 | 8 | 9 | FACTOR_AUTO = 'auto' 10 | FACTOR_PASSCODE = 'passcode' 11 | FACTOR_PHONE = 'phone' 12 | FACTOR_SMS = 'sms' 13 | FACTOR_PUSH = 'push' 14 | 15 | PHONE1 = 'phone1' 16 | PHONE2 = 'phone2' 17 | PHONE3 = 'phone3' 18 | PHONE4 = 'phone4' 19 | PHONE5 = 'phone5' 20 | 21 | 22 | class AuthV1(client.Client): 23 | sig_version = 2 24 | auth_details = False 25 | 26 | def ping(self): 27 | """ 28 | Returns True if and only if the Duo service is up and responding. 29 | """ 30 | response = self.json_api_call('GET', '/rest/v1/ping', {}) 31 | return response == 'pong' 32 | 33 | def check(self): 34 | """ 35 | Returns True if and only if the integration key, secret key, and 36 | signature generation are valid. 37 | """ 38 | response = self.json_api_call('GET', '/rest/v1/check', {}) 39 | return response == 'valid' 40 | 41 | def logo(self): 42 | """ 43 | Retrieve the user-supplied logo. 44 | 45 | Returns the logo on success, raises RuntimeError on failure. 46 | """ 47 | response, data = self.api_call('GET', '/rest/v1/logo', {}) 48 | content_type = response.getheader('Content-Type') 49 | if content_type and content_type.startswith('image/'): 50 | return data 51 | else: 52 | return self.parse_json_response(response, data) 53 | 54 | def preauth(self, username, 55 | ipaddr=None): 56 | params = { 57 | 'user': username, 58 | } 59 | if ipaddr is not None: 60 | params['ipaddr'] = ipaddr 61 | response = self.json_api_call('POST', '/rest/v1/preauth', params) 62 | return response 63 | 64 | def auth(self, username, 65 | factor=FACTOR_PHONE, 66 | auto=None, 67 | passcode=None, 68 | phone=PHONE1, 69 | pushinfo=None, 70 | ipaddr=None, 71 | async_txn=False): 72 | """ 73 | Returns True if authentication was a success, else False. 74 | 75 | If 'async_txn' is True, returns txid of the authentication transaction. 76 | """ 77 | params = { 78 | 'user': username, 79 | 'factor': factor, 80 | } 81 | if async_txn: 82 | params['async'] = '1' 83 | if pushinfo is not None: 84 | params['pushinfo'] = pushinfo 85 | if ipaddr is not None: 86 | params['ipaddr'] = ipaddr 87 | 88 | if factor == FACTOR_AUTO: 89 | params['auto'] = auto 90 | elif factor == FACTOR_PASSCODE: 91 | params['code'] = passcode 92 | elif factor == FACTOR_PHONE: 93 | params['phone'] = phone 94 | elif factor == FACTOR_SMS: 95 | params['phone'] = phone 96 | elif factor == FACTOR_PUSH: 97 | params['phone'] = phone 98 | 99 | response = self.json_api_call('POST', '/rest/v1/auth', params) 100 | if self.auth_details: 101 | return response 102 | if async_txn: 103 | return response['txid'] 104 | return response['result'] == 'allow' 105 | 106 | def status(self, txid): 107 | """ 108 | Returns a 3-tuple: 109 | (complete, success, description) 110 | 111 | complete - True if the authentication request has 112 | completed, else False. 113 | success - True if the authentication request has 114 | completed and was a success, else False. 115 | description - A string describing the current status of the 116 | authentication request. 117 | """ 118 | params = { 119 | 'txid': txid, 120 | } 121 | response = self.json_api_call('GET', '/rest/v1/status', params) 122 | complete = False 123 | success = False 124 | if 'result' in response: 125 | complete = True 126 | success = response['result'] == 'allow' 127 | description = response['status'] 128 | 129 | return (complete, success, description) 130 | -------------------------------------------------------------------------------- /duo_client/https_wrapper.py: -------------------------------------------------------------------------------- 1 | ### The following code was adapted from: 2 | ### https://googleappengine.googlecode.com/svn-history/r136/trunk/python/google/appengine/tools/https_wrapper.py 3 | 4 | # Copyright 2007 Google Inc. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | """Extensions to allow HTTPS requests with SSL certificate validation.""" 20 | 21 | import http.client 22 | import re 23 | import socket 24 | import ssl 25 | import urllib.error 26 | import urllib.request 27 | 28 | class InvalidCertificateException(http.client.HTTPException): 29 | """Raised when a certificate is provided with an invalid hostname.""" 30 | 31 | def __init__(self, host, cert, reason): 32 | """Constructor. 33 | 34 | Args: 35 | host: The hostname the connection was made to. 36 | cert: The SSL certificate (as a dictionary) the host returned. 37 | """ 38 | http.client.HTTPException.__init__(self) 39 | self.host = host 40 | self.cert = cert 41 | self.reason = reason 42 | 43 | def __str__(self): 44 | return ('Host %s returned an invalid certificate (%s): %s\n' 45 | 'To learn more, see ' 46 | 'http://code.google.com/appengine/kb/general.html#rpcssl' % 47 | (self.host, self.reason, self.cert)) 48 | 49 | 50 | class CertValidatingHTTPSConnection(http.client.HTTPConnection): 51 | """An HTTPConnection that connects over SSL and validates certificates.""" 52 | 53 | default_port = http.client.HTTPS_PORT 54 | 55 | def __init__(self, host, port=None, key_file=None, cert_file=None, 56 | ca_certs=None, strict=None, **kwargs): 57 | """Constructor. 58 | 59 | Args: 60 | host: The hostname. Can be in 'host:port' form. 61 | port: The port. Defaults to 443. 62 | key_file: A file containing the client's private key 63 | cert_file: A file containing the client's certificates 64 | ca_certs: A file contianing a set of concatenated certificate authority 65 | certs for validating the server against. 66 | strict: When true, causes BadStatusLine to be raised if the status line 67 | can't be parsed as a valid HTTP/1.0 or 1.1 status line. 68 | """ 69 | http.client.HTTPConnection.__init__(self, host, port, strict, **kwargs) 70 | context = ssl.SSLContext(ssl.PROTOCOL_TLS) 71 | if cert_file: 72 | context.load_cert_chain(cert_file, key_file) 73 | if ca_certs: 74 | context.verify_mode = ssl.CERT_REQUIRED 75 | context.load_verify_locations(cafile=ca_certs) 76 | else: 77 | context.verify_mode = ssl.CERT_NONE 78 | 79 | ssl_version_blacklist = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 80 | context.options |= ssl_version_blacklist 81 | self.default_ssl_context = context 82 | 83 | def _GetValidHostsForCert(self, cert): 84 | """Returns a list of valid host globs for an SSL certificate. 85 | 86 | Args: 87 | cert: A dictionary representing an SSL certificate. 88 | Returns: 89 | list: A list of valid host globs. 90 | """ 91 | if 'subjectAltName' in cert: 92 | return [x[1] for x in cert['subjectAltName'] if x[0].lower() == 'dns'] 93 | else: 94 | return [x[0][1] for x in cert['subject'] 95 | if x[0][0].lower() == 'commonname'] 96 | 97 | def _ValidateCertificateHostname(self, cert, hostname): 98 | """Validates that a given hostname is valid for an SSL certificate. 99 | 100 | Args: 101 | cert: A dictionary representing an SSL certificate. 102 | hostname: The hostname to test. 103 | Returns: 104 | bool: Whether or not the hostname is valid for this certificate. 105 | """ 106 | hosts = self._GetValidHostsForCert(cert) 107 | for host in hosts: 108 | host_re = host.replace('.', r'\.').replace('*', '[^.]*') 109 | if re.search('^%s$' % (host_re,), hostname, re.I): 110 | return True 111 | return False 112 | 113 | def connect(self): 114 | "Connect to a host on a given (SSL) port." 115 | self.sock = socket.create_connection((self.host, self.port), 116 | self.timeout) 117 | if self._tunnel_host: 118 | self._tunnel() 119 | self.sock = self.default_ssl_context.wrap_socket(self.sock, 120 | server_hostname=self.host) 121 | if self.default_ssl_context.verify_mode == ssl.CERT_REQUIRED: 122 | cert = self.sock.getpeercert() 123 | cert_validation_host = self._tunnel_host or self.host 124 | hostname = cert_validation_host.split(':', 0)[0] 125 | if not self._ValidateCertificateHostname(cert, hostname): 126 | raise InvalidCertificateException(hostname, cert, 'hostname mismatch') 127 | 128 | 129 | class CertValidatingHTTPSHandler(urllib.request.HTTPSHandler): 130 | """An HTTPHandler that validates SSL certificates.""" 131 | 132 | def __init__(self, **kwargs): 133 | """Constructor. Any keyword args are passed to the httplib handler.""" 134 | super(CertValidatingHTTPSHandler, self).__init__(self) 135 | self._connection_args = kwargs 136 | 137 | def https_open(self, req): 138 | def http_class_wrapper(host, **kwargs): 139 | full_kwargs = dict(self._connection_args) 140 | full_kwargs.update(kwargs) 141 | return CertValidatingHTTPSConnection(host, **full_kwargs) 142 | try: 143 | return self.do_open(http_class_wrapper, req) 144 | except urllib.error.URLError as e: 145 | if type(e.reason) == ssl.SSLError and e.reason.args[0] == 1: 146 | raise InvalidCertificateException(req.host, '', 147 | e.reason.args[1]) 148 | raise 149 | 150 | https_request = urllib.request.HTTPSHandler.do_request_ 151 | -------------------------------------------------------------------------------- /duo_client/logs/__init__.py: -------------------------------------------------------------------------------- 1 | from .telephony import Telephony 2 | 3 | __all__ = [ 4 | 'Telephony' 5 | ] 6 | -------------------------------------------------------------------------------- /duo_client/logs/telephony.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from duo_client.util import ( 3 | get_params_from_kwargs, 4 | get_log_uri, 5 | get_default_request_times, 6 | ) 7 | 8 | VALID_TELEPHONY_V2_REQUEST_PARAMS = [ 9 | "filters", 10 | "mintime", 11 | "maxtime", 12 | "limit", 13 | "sort", 14 | "next_offset", 15 | "account_id", 16 | ] 17 | 18 | LOG_TYPE = "telephony" 19 | 20 | 21 | class Telephony: 22 | @staticmethod 23 | def get_telephony_logs_v1(json_api_call: Callable, host: str, mintime=0): 24 | # Sanity check mintime as unix timestamp, then transform to string 25 | mintime = f"{int(mintime)}" 26 | params = { 27 | "mintime": mintime, 28 | } 29 | response = json_api_call( 30 | "GET", 31 | get_log_uri(LOG_TYPE, 1), 32 | params, 33 | ) 34 | for row in response: 35 | row["eventtype"] = LOG_TYPE 36 | row["host"] = host 37 | return response 38 | 39 | @staticmethod 40 | def get_telephony_logs_v2(json_api_call: Callable, host: str, **kwargs): 41 | params = {} 42 | default_mintime, default_maxtime = get_default_request_times() 43 | 44 | params = get_params_from_kwargs(VALID_TELEPHONY_V2_REQUEST_PARAMS, **kwargs) 45 | 46 | if "mintime" not in params: 47 | # If mintime is not provided, the script defaults it to 180 days in past 48 | params["mintime"] = default_mintime 49 | params["mintime"] = f"{int(params['mintime'])}" 50 | if "maxtime" not in params: 51 | # if maxtime is not provided, the script defaults it to now 52 | params["maxtime"] = default_maxtime 53 | params["maxtime"] = f"{int(params['maxtime'])}" 54 | if "limit" in params: 55 | params["limit"] = f"{int(params['limit'])}" 56 | 57 | response = json_api_call( 58 | "GET", 59 | get_log_uri(LOG_TYPE, 2), 60 | params, 61 | ) 62 | for row in response["items"]: 63 | row["eventtype"] = LOG_TYPE 64 | row["host"] = host 65 | return response 66 | -------------------------------------------------------------------------------- /duo_client/util.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Sequence, Tuple 2 | from datetime import datetime, timedelta, timezone 3 | 4 | 5 | def get_params_from_kwargs(valid_params: Sequence[str], **kwargs) -> Dict: 6 | params = {} 7 | for k in kwargs: 8 | if kwargs[k] is not None and k in valid_params: 9 | params[k] = kwargs[k] 10 | return params 11 | 12 | 13 | def get_log_uri(log_type: str, version: int = 1) -> str: 14 | return f"/admin/v{version}/logs/{log_type}" 15 | 16 | 17 | def get_default_request_times() -> Tuple[int, int]: 18 | today = datetime.now(tz=timezone.utc) 19 | mintime = int((today - timedelta(days=180)).timestamp() * 1000) 20 | maxtime = int(today.timestamp() * 1000) - 120 21 | return mintime, maxtime 22 | -------------------------------------------------------------------------------- /examples/Accounts/README.md: -------------------------------------------------------------------------------- 1 | # Duo Accounts API Examples Overview 2 | 3 | 4 | ## Examples 5 | 6 | This folder contains various examples to illustrate the usage of the `Accounts` module within the 7 | `duo_client_python` library. The Duo Accounts API is primarily intended for use by Managed Service 8 | Partners (MSP) to assist in the automation of managing their child (customer) Duo accounts. 9 | 10 | Use of the Duo Accounts API requires special access to be enabled. Please see the 11 | [online documentation](https://www.duosecurity.com/docs/accountsapi) for more information. 12 | 13 | # Using 14 | 15 | To run an example query, execute a command like the following from the repo root: 16 | ```python 17 | $ python3 examples/Accounts/get_billing_and_telephony_credits.py 18 | ``` 19 | 20 | Or, from within this folder: 21 | ```python 22 | $ python3 get_billing_and_telephony_credits.py 23 | ``` 24 | 25 | # Tested Against Python Versions 26 | * 3.7 27 | * 3.8 28 | * 3.9 29 | * 3.10 30 | * 3.11 31 | -------------------------------------------------------------------------------- /examples/Accounts/create_child_account.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Accounts API child account creation 3 | """ 4 | 5 | import duo_client 6 | 7 | import sys 8 | import getpass 9 | 10 | from pprint import pprint 11 | 12 | 13 | argv_iter = iter(sys.argv[1:]) 14 | 15 | 16 | def _get_next_arg(prompt, secure=False): 17 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 18 | try: 19 | return next(argv_iter) 20 | except StopIteration: 21 | if secure is True: 22 | return getpass.getpass(prompt) 23 | else: 24 | return input(prompt) 25 | 26 | 27 | def prompt_for_credentials() -> dict: 28 | """Collect required API credentials from command line prompts 29 | 30 | :return: dictionary containing Duo Accounts API ikey, skey and hostname strings 31 | """ 32 | 33 | ikey = _get_next_arg('Duo Accounts API integration key ("DI..."): ') 34 | skey = _get_next_arg('Duo Accounts API integration secret key: ', secure=True) 35 | host = _get_next_arg('Duo Accounts API hostname ("api-....duosecurity.com"): ') 36 | account_name = _get_next_arg('Name for new child account: ') 37 | 38 | return {"IKEY": ikey, "SKEY": skey, "APIHOST": host, "ACCOUNT_NAME": account_name} 39 | 40 | 41 | def main(): 42 | """Main program entry point""" 43 | 44 | inputs = prompt_for_credentials() 45 | 46 | account_client = duo_client.Accounts( 47 | ikey=inputs['IKEY'], 48 | skey=inputs['SKEY'], 49 | host=inputs['APIHOST'] 50 | ) 51 | 52 | print(f"Creating child account with name [{inputs['ACCOUNT_NAME']}]") 53 | child_account = account_client.create_account(inputs['ACCOUNT_NAME']) 54 | 55 | if 'account_id' in child_account: 56 | print(f"Child account for [{inputs['ACCOUNT_NAME']}] created successfully.") 57 | else: 58 | print(f"An unexpected error occurred while creating child account for {inputs['ACCOUNT_NAME']}") 59 | print(child_account) 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /examples/Accounts/create_integration_in_child_account.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of creating an integration in a child account using parent account credentials 3 | 4 | The key to successfully interacting with child accounts via the parent account APIs is 5 | pairing the parent account API IKEY/SKEY combination with the api-host of the child account. 6 | Once that connection is established, the child account ID must be passed along with all API interactions. 7 | The duo_client SDK makes that easy by allowing the setting of the child account ID as an instance variable. 8 | """ 9 | 10 | import sys 11 | import getpass 12 | import duo_client 13 | 14 | # Create an interator to be used by the interactive terminal prompt 15 | argv_iter = iter(sys.argv[1:]) 16 | 17 | 18 | def _get_next_arg(prompt, secure=False): 19 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 20 | try: 21 | return next(argv_iter) 22 | except StopIteration: 23 | if secure is True: 24 | return getpass.getpass(prompt) 25 | else: 26 | return input(prompt) 27 | 28 | 29 | def prompt_for_credentials() -> dict: 30 | """Collect required API credentials from command line prompts 31 | 32 | :return: dictionary containing Duo Accounts API ikey, skey and hostname strings 33 | """ 34 | answers = {'ikey': _get_next_arg('Duo Accounts API integration key ("DI..."): '), 35 | 'skey': _get_next_arg('Duo Accounts API integration secret key: ', secure=True), 36 | 'host': _get_next_arg('Duo API hostname of child account ("api-....duosecurity.com"): '), 37 | 'account_id': _get_next_arg('Child account ID: '), 38 | 'app_name': _get_next_arg('New application name: '), 39 | 'app_type': _get_next_arg('New application type: ')} 40 | return answers 41 | 42 | 43 | def create_child_integration(inputs: dict): 44 | """Create new application integration in child account via the parent account API""" 45 | 46 | # First create a duo_client.Admin instance using the parent account ikey/sky along with the child account api-host 47 | account_client = duo_client.Admin(ikey=inputs['ikey'], skey=inputs['skey'], host=inputs['host']) 48 | # Next assign the child account ID to the duo_client.Admin instance variable. 49 | account_client.account_id = inputs['account_id'] 50 | # Now all API calls made via this instance will contain all of the minimum requirements to interact with the 51 | # child account. 52 | 53 | # Here only the two required arguments (name and type) are passed. 54 | # Normally, much more information would be provided. The type of additional information 55 | # varies by the type of application integration. 56 | try: 57 | new_app = account_client.create_integration( 58 | name=inputs['app_name'], 59 | integration_type=inputs['app_type'], 60 | ) 61 | print(f"New application {inputs['app_name']} (ID: {new_app['integration_key']}) was created successfully.") 62 | except RuntimeError as e_str: 63 | # Any failure of the API call results in a generic Runtime Error 64 | print(f"An error occurred while creating the new application: {e_str}") 65 | 66 | 67 | def main(): 68 | """Main program entry point""" 69 | inputs = prompt_for_credentials() 70 | create_child_integration(inputs) 71 | 72 | 73 | if __name__ == '__main__': 74 | main() 75 | -------------------------------------------------------------------------------- /examples/Accounts/delete_child_account.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Accounts API child account deletiom 3 | """ 4 | 5 | import duo_client 6 | import sys 7 | import getpass 8 | 9 | from pprint import pprint 10 | 11 | 12 | argv_iter = iter(sys.argv[1:]) 13 | 14 | 15 | def _get_next_arg(prompt, secure=False): 16 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 17 | try: 18 | return next(argv_iter) 19 | except StopIteration: 20 | if secure is True: 21 | return getpass.getpass(prompt) 22 | else: 23 | return input(prompt) 24 | 25 | 26 | def prompt_for_credentials() -> dict: 27 | """Collect required API credentials from command line prompts 28 | 29 | :return: dictionary containing Duo Accounts API ikey, skey and hostname strings 30 | """ 31 | 32 | ikey = _get_next_arg('Duo Accounts API integration key ("DI..."): ') 33 | skey = _get_next_arg('Duo Accounts API integration secret key: ', secure=True) 34 | host = _get_next_arg('Duo Accounts API hostname ("api-....duosecurity.com"): ') 35 | account_id = _get_next_arg('ID of child account to delete: ') 36 | 37 | return {"IKEY": ikey, "SKEY": skey, "APIHOST": host, "ACCOUNT_ID": account_id} 38 | 39 | 40 | def main(): 41 | """Main program entry point""" 42 | 43 | inputs = prompt_for_credentials() 44 | 45 | account_client = duo_client.Accounts( 46 | ikey=inputs['IKEY'], 47 | skey=inputs['SKEY'], 48 | host=inputs['APIHOST'] 49 | ) 50 | 51 | account_name = None 52 | child_account_list = account_client.get_child_accounts() 53 | for account in child_account_list: 54 | if account['account_id'] == inputs['ACCOUNT_ID']: 55 | account_name = account['name'] 56 | if account_name is None: 57 | print(f"Unable to find account with ID [{inputs['ACCOUNT_ID']}]") 58 | sys.exit() 59 | 60 | print(f"Deleting child account with name [{account_name}]") 61 | deleted_account = account_client.delete_account(inputs['ACCOUNT_ID']) 62 | if deleted_account == '': 63 | print(f"Account {inputs['ACCOUNT_ID']} was deleted successfully.") 64 | else: 65 | print(f"An unexpected error occurred while deleting account [{account_name}: {deleted_account}]") 66 | 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /examples/Accounts/get_account_edition.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Accounts API get child account edition 3 | """ 4 | 5 | import duo_client 6 | import getpass 7 | 8 | DUO_EDITIONS = { 9 | "ENTERPRISE": "Duo Essentials", 10 | "PLATFORM": "Duo Advantage", 11 | "BEYOND": "Duo Premier", 12 | "PERSONAL": "Duo Free" 13 | } 14 | 15 | def _get_user_input(prompt, secure=False): 16 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 17 | if secure is True: 18 | return getpass.getpass(prompt) 19 | else: 20 | return input(prompt) 21 | 22 | 23 | def prompt_for_credentials() -> dict: 24 | """Collect required API credentials from command line prompts""" 25 | 26 | ikey = _get_user_input('Duo Accounts API integration key ("DI..."): ') 27 | skey = _get_user_input('Duo Accounts API integration secret key: ', secure=True) 28 | host = _get_user_input('Duo Accounts API hostname ("api-....duosecurity.com"): ') 29 | account_id = _get_user_input('Child account ID: ') 30 | 31 | return { 32 | "ikey": ikey, 33 | "skey": skey, 34 | "host": host, 35 | "account_id": account_id, 36 | } 37 | 38 | 39 | def main(): 40 | """Main program entry point""" 41 | 42 | inputs = prompt_for_credentials() 43 | 44 | account_admin_api = duo_client.admin.AccountAdmin(**inputs) 45 | 46 | print(f"Getting edition for account ID {inputs['account_id']}...") 47 | result = account_admin_api.get_edition() 48 | if 'edition' not in result: 49 | print(f"An error occurred while getting edition for account {inputs['account_id']}") 50 | print(f"Error message: {result}") 51 | else: 52 | print(f"The current Duo Edition for account {inputs['account_id']} is '{result['edition']}' " + 53 | f"[{DUO_EDITIONS[result['edition']]}]") 54 | 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /examples/Accounts/get_billing_and_telephony_credits.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | import duo_client 5 | 6 | EDITIONS = { 7 | "ENTERPRISE": "Duo Essentials", 8 | "PLATFORM": "Duo Advantage", 9 | "BEYOND": "Duo Premier", 10 | "PERSONAL": "Duo Free" 11 | } 12 | 13 | def get_next_input(prompt): 14 | try: 15 | return next(iter(sys.argv[1:])) 16 | except StopIteration: 17 | return input(prompt) 18 | 19 | 20 | def main(): 21 | """Program entry point""" 22 | ikey=get_next_input('Accounts API integration key ("DI..."): ') 23 | skey=get_next_input('Accounts API integration secret key: ') 24 | host=get_next_input('Accounts API hostname ("api-....duosecurity.com"): ') 25 | 26 | # Configuration and information about objects to create. 27 | accounts_api = duo_client.Accounts( 28 | ikey=ikey, 29 | skey=skey, 30 | host=host, 31 | ) 32 | 33 | kwargs = { 34 | 'ikey': ikey, 35 | 'skey': skey, 36 | 'host': host, 37 | } 38 | 39 | # Get all child accounts 40 | child_accounts = accounts_api.get_child_accounts() 41 | 42 | for child_account in child_accounts: 43 | # Create AccountAdmin with child account_id, child api_hostname and kwargs consisting of ikey, skey, and host 44 | account_admin_api = duo_client.admin.AccountAdmin( 45 | child_account['account_id'], 46 | child_api_host = child_account['api_hostname'], 47 | **kwargs, 48 | ) 49 | try: 50 | # Get edition of child account 51 | child_account_edition = account_admin_api.get_edition() 52 | print(f"Edition for child account {child_account['name']}: {child_account_edition['edition']}") 53 | except RuntimeError as err: 54 | # The account might not have access to get billing information 55 | if "Received 403 Access forbidden" == str(err): 56 | print("{error}: No access for billing feature".format(error=err)) 57 | else: 58 | print(err) 59 | 60 | try: 61 | # Get telephony credits of child account 62 | child_telephony_credits = account_admin_api.get_telephony_credits() 63 | print("Telephony credits for child account {name}: {edition}".format( 64 | name=child_account['name'], 65 | edition=child_telephony_credits['credits']) 66 | ) 67 | except RuntimeError as err: 68 | # The account might not have access to get telephony credits 69 | if "Received 403 Access forbidden" == str(err): 70 | print("{error}: No access for telephony feature".format(error=err)) 71 | else: 72 | print(err) 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /examples/Accounts/retrieve_account_list.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo account API uaer accountentication with synchronous request/response 3 | """ 4 | 5 | import duo_client 6 | import sys 7 | import getpass 8 | 9 | from pprint import pprint 10 | 11 | 12 | argv_iter = iter(sys.argv[1:]) 13 | 14 | 15 | def _get_next_arg(prompt, secure=False): 16 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 17 | try: 18 | return next(argv_iter) 19 | except StopIteration: 20 | if secure is True: 21 | return getpass.getpass(prompt) 22 | else: 23 | return input(prompt) 24 | 25 | 26 | def prompt_for_credentials() -> dict: 27 | """Collect required API credentials from command line prompts 28 | 29 | :return: dictionary containing Duo Accounts API ikey, skey and hostname strings 30 | """ 31 | 32 | ikey = _get_next_arg('Duo Accounts API integration key ("DI..."): ') 33 | skey = _get_next_arg('Duo Accounts API integration secret key: ', secure=True) 34 | host = _get_next_arg('Duo Accounts API hostname ("api-....duosecurity.com"): ') 35 | 36 | return {"IKEY": ikey, "SKEY": skey, "APIHOST": host} 37 | 38 | 39 | def main(): 40 | """Main program entry point""" 41 | 42 | inputs = prompt_for_credentials() 43 | 44 | account_client = duo_client.Accounts( 45 | ikey=inputs['IKEY'], 46 | skey=inputs['SKEY'], 47 | host=inputs['APIHOST'] 48 | ) 49 | 50 | child_accounts = account_client.get_child_accounts() 51 | 52 | if isinstance(child_accounts, list): 53 | # Expected list of child accounts returned 54 | for child_account in child_accounts: 55 | print(child_account) 56 | 57 | if isinstance(child_accounts, dict): 58 | # Non-successful response returned 59 | print(child_accounts) 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /examples/Accounts/retrieve_integrations_from_child_account.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of creating an integration in a child account using parent account credentials 3 | """ 4 | 5 | import argparse 6 | import duo_client 7 | 8 | 9 | parser = argparse.ArgumentParser() 10 | duo_arg_group = parser.add_argument_group('Duo Accounts API Credentials') 11 | duo_arg_group.add_argument('--ikey', 12 | help='Duo Accounts API IKEY', 13 | required=True 14 | ) 15 | duo_arg_group.add_argument('--skey', 16 | help='Duo Accounts API Secret Key', 17 | required=True, 18 | ) 19 | duo_arg_group.add_argument('--host', 20 | help='Duo child account API apihost', 21 | required=True 22 | ) 23 | parser.add_argument('--child_account_id', 24 | help='The Duo account ID of the child account to query.', 25 | required=True 26 | ) 27 | args = parser.parse_args() 28 | 29 | # It is important to note that we are using the IKEY/SKEY combination for an Accounts API integration in the 30 | # parent account along with the api-hostname of a child account to create a new duo_client.Admin instance 31 | account_client = duo_client.Admin( 32 | ikey=args.ikey, 33 | skey=args.skey, 34 | host=args.host, 35 | ) 36 | 37 | # Once the duo_client.Admin instance is created, the child account_id is assigned. This is necessary to ensure 38 | # queries made with this Admin API instance are directed to the proper child account that matches the api-hostname 39 | # used to create the instance. 40 | account_client.account_id = args.child_account_id 41 | 42 | 43 | def main(): 44 | """Main program entry point""" 45 | 46 | print(f"Retrieving integrations for child account {args.child_account_id}") 47 | child_account_integrations = account_client.get_integrations_generator() 48 | for integration in child_account_integrations: 49 | print(f"{integration['name']=}") 50 | 51 | 52 | if __name__ == '__main__': 53 | main() 54 | -------------------------------------------------------------------------------- /examples/Accounts/set_account_edition.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Accounts API set child account edition 3 | """ 4 | 5 | import duo_client 6 | import getpass 7 | 8 | ALLOWED_DUO_EDITIONS = ("PERSONAL", "ENTERPRISE", "PLATFORM", "BEYOND") 9 | 10 | def _get_user_input(prompt, secure=False): 11 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 12 | if secure is True: 13 | return getpass.getpass(prompt) 14 | else: 15 | return input(prompt) 16 | 17 | 18 | def prompt_for_credentials() -> dict: 19 | """Collect required API credentials from command line prompts""" 20 | 21 | ikey = _get_user_input('Duo Accounts API integration key ("DI..."): ') 22 | skey = _get_user_input('Duo Accounts API integration secret key: ', secure=True) 23 | host = _get_user_input('Duo Accounts API hostname ("api-....duosecurity.com"): ') 24 | account_id = _get_user_input('Child account ID: ') 25 | account_apihost = _get_user_input('Child account api_hostname: ') 26 | account_edition = _get_user_input('Child account edition: ') 27 | while account_edition.upper() not in ALLOWED_DUO_EDITIONS: 28 | print(f"Invalid account edition. Please select one of {ALLOWED_DUO_EDITIONS}") 29 | account_edition = _get_user_input('Child account edition: ') 30 | 31 | return { 32 | "ikey": ikey, 33 | "skey": skey, 34 | "host": host, 35 | "account_id": account_id, 36 | "child_api_host": account_apihost, 37 | "account_edition": account_edition, 38 | } 39 | 40 | 41 | def main(): 42 | """Main program entry point""" 43 | 44 | inputs = prompt_for_credentials() 45 | edition = inputs.pop('account_edition') 46 | edition = edition.upper() 47 | 48 | account_admin_api = duo_client.admin.AccountAdmin(**inputs) 49 | 50 | print(f"Setting edition for account ID {inputs['account_id']} to {edition}") 51 | result = account_admin_api.set_edition(edition) 52 | if result != "": 53 | print(f"An error occurred while setting edition for account {inputs['account_id']}") 54 | print(f"Error message: {result}") 55 | else: 56 | print(f"Edition [{edition}] successfully set for account ID {inputs['account_id']}") 57 | 58 | 59 | if __name__ == '__main__': 60 | main() 61 | -------------------------------------------------------------------------------- /examples/Admin/README.md: -------------------------------------------------------------------------------- 1 | # Duo Admin API Examples Overview 2 | 3 | 4 | ## Examples 5 | 6 | This folder contains various examples to illustrate the usage of the `Admin` module within the 7 | `duo_client_python` library. The Duo Admin API is primarily intended for automating the management 8 | account level elements within a customer configuration such as: 9 | 10 | - Users 11 | - Groups 12 | - Phones/Tablets 13 | - Tokens 14 | - Application integrations 15 | - Policies 16 | - Logs 17 | 18 | # Using 19 | 20 | To run an example query, execute a command like the following from the repo root: 21 | ```python 22 | $ python3 examples/Admin/report_users_and_phones.py 23 | ``` 24 | 25 | Or, from within this folder: 26 | ```python 27 | $ python3 report_users_and_phones.py 28 | ``` 29 | 30 | # Tested Against Python Versions 31 | * 3.7 32 | * 3.8 33 | * 3.9 34 | * 3.10 35 | * 3.11 36 | -------------------------------------------------------------------------------- /examples/Admin/create_admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import pprint 3 | import sys 4 | 5 | import duo_client 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | def get_next_arg(prompt): 9 | try: 10 | return next(argv_iter) 11 | except StopIteration: 12 | return input(prompt) 13 | 14 | # Configuration and information about objects to create. 15 | admin_api = duo_client.Admin( 16 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 17 | skey=get_next_arg('integration secret key: '), 18 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 19 | ) 20 | 21 | NAME = get_next_arg('Admin name: ') 22 | EMAIL = get_next_arg('Email: ') 23 | PHONE = get_next_arg('Phone number (e.g. 2154567890): ') 24 | PASSWORD = get_next_arg('Password: ') 25 | ROLE = get_next_arg('Administrative Role(Optional): ') or None 26 | SUBACCOUNT_ROLE = get_next_arg('Subaccount Role(Optional): ') or None 27 | 28 | created_admin = admin_api.add_admin(NAME, EMAIL, PHONE, PASSWORD, ROLE, SUBACCOUNT_ROLE) 29 | print('Created Admin: ') 30 | pprint.pprint(created_admin) 31 | -------------------------------------------------------------------------------- /examples/Admin/create_integration_sso_generic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import pprint 3 | import sys 4 | 5 | import duo_client 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | 9 | 10 | def get_next_arg(prompt): 11 | try: 12 | return next(argv_iter) 13 | except StopIteration: 14 | return input(prompt) 15 | 16 | 17 | ikey = get_next_arg('Admin API integration key ("DI..."): ') 18 | skey = get_next_arg('integration secret key: ') 19 | host = get_next_arg('API hostname ("api-....duosecurity.com"): ') 20 | 21 | # Configuration and information about objects to create. 22 | admin_api = duo_client.Admin( 23 | ikey, 24 | skey, 25 | host, 26 | ) 27 | 28 | integration = admin_api.create_integration( 29 | name='api-created integration', 30 | integration_type='sso-generic', 31 | sso={ 32 | "saml_config": { 33 | "entity_id": "entity_id", 34 | "acs_urls": [ 35 | { 36 | "url": "https://example.com/acs", 37 | "binding": None, 38 | "isDefault": None, 39 | "index": None, 40 | } 41 | ], 42 | "nameid_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 43 | "nameid_attribute": "mail", 44 | "sign_assertion": False, 45 | "sign_response": True, 46 | "signing_algorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", 47 | "mapped_attrs": {}, 48 | "relaystate": "https://example.com/relaystate", 49 | "slo_url": "https://example.com/slo", 50 | "spinitiated_url": "https://example.com/spurl", 51 | "static_attrs": {}, 52 | "role_attrs": { 53 | "bob": { 54 | "ted": ["DGS08MMO53GNRLSFW0D0", "DGETXINZ6CSJO4LRSVKV"], 55 | "frank": ["DGETXINZ6CSJO4LRSVKV"], 56 | } 57 | }, 58 | "attribute_transformations": { 59 | "attribute_1": 'use ""\nprepend text="dev-"', 60 | "attribute_2": 'use ""\nappend additional_attr=""', 61 | } 62 | } 63 | }, 64 | ) 65 | 66 | print('Created integration:') 67 | pprint.pprint(integration) 68 | -------------------------------------------------------------------------------- /examples/Admin/create_user_and_phone.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import pprint 3 | import sys 4 | 5 | import duo_client 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | def get_next_arg(prompt): 9 | try: 10 | return next(argv_iter) 11 | except StopIteration: 12 | return input(prompt) 13 | 14 | # Configuration and information about objects to create. 15 | admin_api = duo_client.Admin( 16 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 17 | skey=get_next_arg('integration secret key: '), 18 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 19 | ) 20 | 21 | USERNAME = get_next_arg('user login name: ') 22 | REALNAME = get_next_arg('user full name: ') 23 | 24 | # Refer to http://www.duosecurity.com/docs/adminapi for more 25 | # information about phone types and platforms. 26 | PHONE_NUMBER = get_next_arg('phone number (e.g. +1-555-123-4567): ') 27 | PHONE_TYPE = get_next_arg('phone type (e.g. mobile): ') 28 | PHONE_PLATFORM = get_next_arg('phone platform (e.g. google android): ') 29 | 30 | # Create and return a new user object. 31 | user = admin_api.add_user( 32 | username=USERNAME, 33 | realname=REALNAME, 34 | ) 35 | print('Created user:') 36 | pprint.pprint(user) 37 | 38 | # Create and return a new phone object. 39 | phone = admin_api.add_phone( 40 | number=PHONE_NUMBER, 41 | type=PHONE_TYPE, 42 | platform=PHONE_PLATFORM, 43 | ) 44 | print('Created phone:') 45 | pprint.pprint(phone) 46 | 47 | # Associate the user with the phone. 48 | admin_api.add_user_phone( 49 | user_id=user['user_id'], 50 | phone_id=phone['phone_id'], 51 | ) 52 | print('Added phone', phone['number'], 'to user', user['username']) 53 | 54 | # Send two SMS messages to the phone with information about installing 55 | # the app for PHONE_PLATFORM and activating it with this Duo account. 56 | act_sent = admin_api.send_sms_activation_to_phone( 57 | phone_id=phone['phone_id'], 58 | install='1', 59 | ) 60 | print('SMS activation sent to', phone['number'] + ':') 61 | pprint.pprint(act_sent) 62 | -------------------------------------------------------------------------------- /examples/Admin/get_users_in_group_with_aliases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of how to extract users and their aliases for a specific group from the Duo Admin API 3 | """ 4 | 5 | import sys 6 | import argparse 7 | import dataclasses 8 | from collections import deque 9 | from duo_client import Admin 10 | 11 | DUO_MAX_USERS_PER_API_CALL = 100 12 | 13 | 14 | @dataclasses.dataclass 15 | class DuoUser: 16 | """ 17 | Duo User object class for storage and retrieval of pertinent information per user 18 | """ 19 | username: str = None 20 | user_id: str = None 21 | group_name: str = None 22 | aliases: str = None 23 | 24 | def set_group_name(self, group_name): 25 | self.group_name = group_name 26 | 27 | def set_aliases(self, aliases: list): 28 | self.aliases = ','.join(aliases) 29 | 30 | def get_user_info(self): 31 | return_string = (f"'username': {self.username}, 'user_id': {self.user_id}, " + 32 | f"'group_name': {self.group_name}, 'aliases': '{self.aliases}'") 33 | return return_string 34 | 35 | 36 | parser = argparse.ArgumentParser() 37 | duo_arg_group = parser.add_argument_group('Duo Admin API Credentials') 38 | duo_arg_group.add_argument('--ikey', 39 | help='Duo Admin API IKEY', 40 | required=True 41 | ) 42 | duo_arg_group.add_argument('--skey', 43 | help='Duo Admin API Secret Key', 44 | required=True, 45 | ) 46 | duo_arg_group.add_argument('--host', 47 | help='Duo Admin API apihost', 48 | required=True 49 | ) 50 | parser.add_argument('--group_name', 51 | help="Name of group to get users from. Groups are case-sensitive.", 52 | required=True 53 | ) 54 | args = parser.parse_args() 55 | 56 | duo_admin_client = Admin( 57 | ikey=args.ikey, 58 | skey=args.skey, 59 | host=args.host 60 | ) 61 | 62 | 63 | def split_list(input_list: list, size: int) -> list: 64 | """Split a list into chunks based on size""" 65 | return [input_list[i:i + size] for i in range(0, len(input_list), size)] 66 | 67 | 68 | def get_duo_group_users(group_name: str) -> list: 69 | """Get the list of users assigned to the given group name""" 70 | group_id = None 71 | try: 72 | groups = (duo_admin_client.get_groups()) 73 | except Exception as e_str: 74 | print(f"Exception while retrieving groups: {e_str}") 75 | sys.exit(1) 76 | 77 | for group in groups: 78 | if group['name'] == group_name: 79 | group_id = group['group_id'] 80 | break 81 | 82 | user_list = list(deque(duo_admin_client.get_group_users_iterator(group_id))) 83 | return split_list(user_list, DUO_MAX_USERS_PER_API_CALL) 84 | 85 | 86 | def get_duo_user_aliases(user_list: list[list]) -> list: 87 | """Collect aliases for the users in the given list""" 88 | all_users = [] 89 | for u_list in user_list: 90 | users = list(deque(duo_admin_client.get_users_by_ids([uid['user_id'] for uid in u_list]))) 91 | for user in users: 92 | new_duo_user = DuoUser(user_id=user['user_id'], username=user['username']) 93 | new_duo_user.set_aliases(list(user['aliases'].values())) 94 | all_users.append(new_duo_user) 95 | return all_users 96 | 97 | 98 | def output_user_aliases(user_list: list[DuoUser], group_name: str) -> None: 99 | """Output the list of users and their aliases for the requested group""" 100 | for user in user_list: 101 | user.set_group_name(group_name) 102 | print(user.get_user_info()) 103 | 104 | 105 | def main(): 106 | """Main program entry point""" 107 | if args.group_name is not None: 108 | duo_group_users = get_duo_group_users(args.group_name) 109 | if len(duo_group_users) == 0: 110 | print(f"Unable to find users assigned to group named '{args.group_name}'.") 111 | sys.exit(1) 112 | duo_group_user_aliases = get_duo_user_aliases(duo_group_users) 113 | output_user_aliases(duo_group_user_aliases, args.group_name) 114 | 115 | 116 | if __name__ == '__main__': 117 | main() 118 | -------------------------------------------------------------------------------- /examples/Admin/log_examples.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import csv 3 | import sys 4 | from datetime import datetime, timedelta, timezone 5 | 6 | import duo_client 7 | 8 | argv_iter = iter(sys.argv[1:]) 9 | 10 | 11 | def get_next_arg(prompt, default=None): 12 | try: 13 | return next(argv_iter) 14 | except StopIteration: 15 | return input(prompt) or default 16 | 17 | 18 | today = datetime.now(tz=timezone.utc) 19 | default_mintime = int((today - timedelta(days=180)).timestamp()) 20 | default_maxtime = int(today.timestamp() * 1000) - 120 21 | 22 | # Configuration and information about objects to create. 23 | admin_api = duo_client.Admin( 24 | ikey=get_next_arg("Admin API integration key: "), 25 | skey=get_next_arg("Integration secret key: "), 26 | host=get_next_arg("API hostname: "), 27 | ) 28 | params = {} 29 | 30 | mintime = get_next_arg("Mintime: ", default_mintime) 31 | if mintime: 32 | params["mintime"] = mintime 33 | 34 | maxtime = get_next_arg("Maxtime: ", default_maxtime) 35 | if maxtime: 36 | params["maxtime"] = maxtime 37 | 38 | limit = get_next_arg("Limit (1000): ") 39 | if limit: 40 | params["limit"] = limit 41 | 42 | next_offset = get_next_arg("Next_offset: ") 43 | if next_offset: 44 | params["next_offset"] = next_offset 45 | 46 | sort = get_next_arg("Sort (ts:desc): ") 47 | if sort: 48 | params["sort"] = sort 49 | 50 | log_type = get_next_arg("Log Type (telephony_v2): ", "telephony_v2") 51 | print(f"Fetching {log_type} logs...") 52 | reporter = csv.writer(sys.stdout) 53 | 54 | print("==============================") 55 | if log_type == "activity": 56 | params["mintime"] = params["mintime"] * 1000 57 | activity_logs = admin_api.get_activity_logs(**params) 58 | print( 59 | "Next offset from response: ", activity_logs.get("metadata").get("next_offset") 60 | ) 61 | reporter.writerow( 62 | ("activity_id", "ts", "action", "actor_name", "target_name", "application") 63 | ) 64 | for log in activity_logs["items"]: 65 | activity = log["activity_id"] 66 | ts = log["ts"] 67 | action = log["action"] 68 | actor_name = log.get("actor", {}).get("name", None) 69 | target_name = log.get("target", {}).get("name", None) 70 | application = log.get("application", {}).get("name", None) 71 | reporter.writerow( 72 | [ 73 | activity, 74 | ts, 75 | action, 76 | actor_name, 77 | target_name, 78 | application, 79 | ] 80 | ) 81 | if log_type == "telephony_v2": 82 | telephony_logs = admin_api.get_telephony_log(api_version=2, **params) 83 | reporter.writerow(("telephony_id", "txid", "credits", "context", "phone", "type")) 84 | 85 | for log in telephony_logs["items"]: 86 | telephony_id = log["telephony_id"] 87 | txid = log["txid"] 88 | credits = log["credits"] 89 | context = log["context"] 90 | phone = log["phone"] 91 | type = log["type"] 92 | reporter.writerow( 93 | [ 94 | telephony_id, 95 | txid, 96 | credits, 97 | context, 98 | phone, 99 | type 100 | ] 101 | ) 102 | if log_type == "auth": 103 | auth_logs = admin_api.get_authentication_log(api_version=2, kwargs=params) 104 | print( 105 | "Next offset from response: ", 106 | auth_logs.get("metadata").get("next_offset"), 107 | ) 108 | reporter.writerow(("admin", "akey", "context", "phone", "provider")) 109 | for log in auth_logs["authlogs"]: 110 | admin = log["admin_name"] 111 | akey = log["akey"] 112 | context = log["context"] 113 | phone = log["phone"] 114 | provider = log["provider"] 115 | reporter.writerow( 116 | [ 117 | admin, 118 | akey, 119 | context, 120 | phone, 121 | provider, 122 | ] 123 | ) 124 | 125 | print("==============================") 126 | -------------------------------------------------------------------------------- /examples/Admin/policies.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import json 4 | import duo_client 5 | 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | 9 | 10 | def get_next_arg(prompt): 11 | try: 12 | return next(argv_iter) 13 | except StopIteration: 14 | return input(prompt) 15 | 16 | 17 | admin_api = duo_client.Admin( 18 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 19 | skey=get_next_arg("integration secret key: "), 20 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 21 | ) 22 | 23 | 24 | def create_empty_policy(name, print_response=False): 25 | """ 26 | Create an empty policy with a specified name. 27 | """ 28 | 29 | json_request = { 30 | "policy_name": name, 31 | } 32 | response = admin_api.create_policy_v2(json_request) 33 | if print_response: 34 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 35 | print(pretty) 36 | return response.get("policy_key") 37 | 38 | 39 | def create_policy_browsers(name, print_response=False): 40 | """ 41 | Create a policy that blocks internet explorer browsers. Requires 42 | Access or Beyond editions. 43 | """ 44 | 45 | json_request = { 46 | "policy_name": name, 47 | "sections": { 48 | "browsers": { 49 | "blocked_browsers_list": [ 50 | "ie", 51 | ], 52 | }, 53 | }, 54 | } 55 | response = admin_api.create_policy_v2(json_request) 56 | if print_response: 57 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 58 | print(pretty) 59 | return response.get("policy_key") 60 | 61 | def copy_policy(name1, name2, copy_from, print_response=False): 62 | """ 63 | Copy the policy `copy_from` to two new policies. 64 | """ 65 | response = admin_api.copy_policy_v2(copy_from, [name1, name2]) 66 | if print_response: 67 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 68 | print(pretty) 69 | policies = response.get("policies") 70 | return (policies[0].get("policy_key"), policies[1].get("policy_key")) 71 | 72 | def bulk_delete_section(policy_keys, print_response=False): 73 | """ 74 | Delete the section "browsers" from the provided policies. 75 | """ 76 | response = admin_api.update_policies_v2("", ["browsers"], policy_keys) 77 | if print_response: 78 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 79 | print(pretty) 80 | 81 | def update_policy_with_device_health_app(policy_key, print_response=False): 82 | """ 83 | Update a given policy to include Duo Device Health App policy 84 | settings. Requires Access or Beyond editions. 85 | NOTE: this function is deprecated, please use update_policy_with_duo_desktop 86 | """ 87 | return update_policy_with_duo_desktop(policy_key, print_response) 88 | 89 | def update_policy_with_duo_desktop(policy_key, print_response=False): 90 | """ 91 | Update a given policy to include Duo Desktop policy 92 | settings. Requires Access or Beyond editions. 93 | """ 94 | 95 | json_request = { 96 | "sections": { 97 | "duo_desktop": { 98 | "enforce_encryption": ["windows"], 99 | "enforce_firewall": ["windows"], 100 | "requires_duo_desktop": ["windows"], 101 | "windows_endpoint_security_list": ["cisco-amp"], 102 | "windows_remediation_note": "Please install Windows agent", 103 | }, 104 | }, 105 | } 106 | response = admin_api.update_policy_v2(policy_key, json_request) 107 | if print_response: 108 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 109 | print(pretty) 110 | return response.get("policy_key") 111 | 112 | 113 | def get_policy(policy_key): 114 | """ 115 | Fetch a given policy. 116 | """ 117 | 118 | response = admin_api.get_policy_v2(policy_key) 119 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 120 | print(pretty) 121 | 122 | 123 | def iterate_all_policies(): 124 | """ 125 | Loop over each policy. 126 | """ 127 | 128 | print("#####################") 129 | print("Iterating over all policies...") 130 | print("#####################") 131 | iter = sorted( 132 | admin_api.get_policies_v2_iterator(), key=lambda x: x.get("policy_name") 133 | ) 134 | for policy in iter: 135 | print( 136 | "##################### {} {}".format( 137 | policy.get("policy_name"), policy.get("policy_key") 138 | ) 139 | ) 140 | pretty = json.dumps(policy, indent=4, sort_keys=True, default=str) 141 | print(pretty) 142 | 143 | 144 | def main(): 145 | # Create two empty policies 146 | policy_key_a = create_empty_policy("Test New Policy - a") 147 | policy_key_b = create_empty_policy("Test New Policy - b") 148 | 149 | # Update policy with Duo Desktop settings. 150 | update_policy_with_duo_desktop(policy_key_b) 151 | 152 | # Create an empty policy and delete it. 153 | policy_key_c = create_empty_policy("Test New Policy - c") 154 | admin_api.delete_policy_v2(policy_key_c) 155 | 156 | # Create a policy with browser restriction settings. 157 | policy_key_d = create_policy_browsers("Test New Policy - d") 158 | 159 | # Copy a policy to 2 new policies. 160 | policy_key_e, policy_key_f = copy_policy("Test New Policy - e", "Test New Policy - f", policy_key_d) 161 | 162 | # Delete the browser restriction settings from 2 policies. 163 | bulk_delete_section([policy_key_e, policy_key_f]) 164 | 165 | # Fetch the global and other custom policy. 166 | get_policy("global") 167 | get_policy(policy_key_b) 168 | 169 | # Loop over each policy. 170 | iterate_all_policies() 171 | 172 | 173 | if __name__ == "__main__": 174 | main() 175 | -------------------------------------------------------------------------------- /examples/Admin/policies_advanced.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Admin API policies operations 3 | """ 4 | import json 5 | import duo_client 6 | from getpass import getpass 7 | 8 | 9 | class DuoPolicy(): 10 | """Base class for Duo Policy object properties and methods""" 11 | 12 | def __init__(self): 13 | """Initialize Duo Policy""" 14 | ... 15 | 16 | 17 | def get_next_user_input(prompt: str, secure: bool = False) -> str: 18 | """Collect input from user via standard input device""" 19 | return getpass(prompt) if secure is True else input(prompt) 20 | 21 | 22 | admin_api = duo_client.Admin( 23 | ikey=get_next_user_input('Admin API integration key ("DI..."): '), 24 | skey=get_next_user_input("Admin API integration secret key: ", secure=True), 25 | host=get_next_user_input('API hostname ("api-....duosecurity.com"): '), 26 | ) 27 | 28 | 29 | def create_empty_policy(name, print_response=False): 30 | """ 31 | Create an empty policy with a specified name. 32 | """ 33 | 34 | json_request = { 35 | "policy_name": name, 36 | } 37 | response = admin_api.create_policy_v2(json_request) 38 | if print_response: 39 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 40 | print(pretty) 41 | return response.get("policy_key") 42 | 43 | 44 | def create_policy_browsers(name, print_response=False): 45 | """ 46 | Create a policy that blocks internet explorer browsers. Requires 47 | Access or Beyond editions. 48 | """ 49 | 50 | json_request = { 51 | "policy_name": name, 52 | "sections": { 53 | "browsers": { 54 | "blocked_browsers_list": [ 55 | "ie", 56 | ], 57 | }, 58 | }, 59 | } 60 | response = admin_api.create_policy_v2(json_request) 61 | if print_response: 62 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 63 | print(pretty) 64 | return response.get("policy_key") 65 | 66 | 67 | def copy_policy(name1, name2, copy_from, print_response=False): 68 | """ 69 | Copy the policy `copy_from` to two new policies. 70 | """ 71 | response = admin_api.copy_policy_v2(copy_from, [name1, name2]) 72 | if print_response: 73 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 74 | print(pretty) 75 | policies = response.get("policies") 76 | return (policies[0].get("policy_key"), policies[1].get("policy_key")) 77 | 78 | 79 | def bulk_delete_section(policy_keys, print_response=False): 80 | """ 81 | Delete the section "browsers" from the provided policies. 82 | """ 83 | response = admin_api.update_policies_v2("", ["browsers"], policy_keys) 84 | if print_response: 85 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 86 | print(pretty) 87 | 88 | def update_policy_with_device_health_app(policy_key, print_response=False): 89 | """ 90 | Update a given policy to include Duo Device Health App policy 91 | settings. Requires Access or Beyond editions. 92 | NOTE: this function is deprecated, please use update_policy_with_duo_desktop 93 | """ 94 | return update_policy_with_duo_desktop(policy_key, print_response) 95 | 96 | def update_policy_with_duo_desktop(policy_key, print_response=False): 97 | """ 98 | Update a given policy to include Duo Desktop policy 99 | settings. Requires Access or Beyond editions. 100 | """ 101 | 102 | json_request = { 103 | "sections": { 104 | "duo_desktop": { 105 | "enforce_encryption": ["windows"], 106 | "enforce_firewall": ["windows"], 107 | "requires_duo_desktop": ["windows"], 108 | "windows_endpoint_security_list": ["cisco-amp"], 109 | "windows_remediation_note": "Please install Windows agent", 110 | }, 111 | }, 112 | } 113 | response = admin_api.update_policy_v2(policy_key, json_request) 114 | if print_response: 115 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 116 | print(pretty) 117 | return response.get("policy_key") 118 | 119 | 120 | def get_policy(policy_key): 121 | """ 122 | Fetch a given policy. 123 | """ 124 | 125 | response = admin_api.get_policy_v2(policy_key) 126 | pretty = json.dumps(response, indent=4, sort_keys=True, default=str) 127 | print(pretty) 128 | 129 | 130 | def iterate_all_policies(): 131 | """ 132 | Loop over each policy. 133 | """ 134 | 135 | print("#####################") 136 | print("Iterating over all policies...") 137 | print("#####################") 138 | iter = sorted( 139 | admin_api.get_policies_v2_iterator(), key=lambda x: x.get("policy_name") 140 | ) 141 | for policy in iter: 142 | print( 143 | "##################### {} {}".format( 144 | policy.get("policy_name"), policy.get("policy_key") 145 | ) 146 | ) 147 | pretty = json.dumps(policy, indent=4, sort_keys=True, default=str) 148 | print(pretty) 149 | 150 | 151 | def main(): 152 | """Primary program entry point""" 153 | # Create two empty policies 154 | policy_key_a = create_empty_policy("Test New Policy - a") 155 | policy_key_b = create_empty_policy("Test New Policy - b") 156 | 157 | # Update policy with Duo Desktop settings. 158 | update_policy_with_duo_desktop(policy_key_b) 159 | 160 | # Create an empty policy and delete it. 161 | policy_key_c = create_empty_policy("Test New Policy - c") 162 | admin_api.delete_policy_v2(policy_key_c) 163 | 164 | # Create a policy with browser restriction settings. 165 | policy_key_d = create_policy_browsers("Test New Policy - d") 166 | 167 | # Copy a policy to 2 new policies. 168 | policy_key_e, policy_key_f = copy_policy("Test New Policy - e", "Test New Policy - f", policy_key_d) 169 | 170 | # Delete the browser restriction settings from 2 policies. 171 | bulk_delete_section([policy_key_e, policy_key_f]) 172 | 173 | # Fetch the global and other custom policy. 174 | get_policy("global") 175 | get_policy(policy_key_b) 176 | 177 | # Loop over each policy. 178 | iterate_all_policies() 179 | 180 | 181 | if __name__ == "__main__": 182 | main() 183 | -------------------------------------------------------------------------------- /examples/Admin/report_auths_by_country.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import csv 3 | import sys 4 | import duo_client 5 | 6 | argv_iter = iter(sys.argv[1:]) 7 | def get_next_arg(prompt): 8 | try: 9 | return next(argv_iter) 10 | except StopIteration: 11 | return input(prompt) 12 | 13 | # Configuration and information about objects to create. 14 | admin_api = duo_client.Admin( 15 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 16 | skey=get_next_arg('integration secret key: '), 17 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 18 | ) 19 | 20 | # Retrieve log info from API: 21 | logs = admin_api.get_authentication_log() 22 | 23 | # Count authentications by country: 24 | counts = dict() 25 | for log in logs: 26 | country = log['location']['country'] 27 | if country != '': 28 | counts[country] = counts.get(country, 0) + 1 29 | 30 | # Print CSV of country, auth count: 31 | auths_descending = sorted(counts.items(), reverse=True) 32 | reporter = csv.writer(sys.stdout) 33 | print("[+] Report of auth counts by country:") 34 | reporter.writerow(('Country', 'Auth Count')) 35 | for row in auths_descending: 36 | reporter.writerow([ 37 | row[0], 38 | row[1], 39 | ]) 40 | -------------------------------------------------------------------------------- /examples/Admin/report_user_activity.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from datetime import datetime, timezone 4 | 5 | import duo_client 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | 9 | 10 | def get_next_input(prompt): 11 | """Collect user input from terminal and return it.""" 12 | try: 13 | return next(argv_iter) 14 | except StopIteration: 15 | return input(prompt) 16 | 17 | 18 | def human_time(time: int) -> str: 19 | """Translate unix time into human readable string""" 20 | if time is None: 21 | date_str = 'Never' 22 | else: 23 | date_str = datetime.fromtimestamp(time, timezone.utc).strftime("%Y-%m-%m %H:%M:%S") 24 | return date_str 25 | 26 | 27 | # Configuration and information about objects to create. 28 | admin_api = duo_client.Admin( 29 | ikey=get_next_input('Admin API integration key ("DI..."): '), 30 | skey=get_next_input('integration secret key: '), 31 | host=get_next_input('API hostname ("api-....duosecurity.com"): '), ) 32 | 33 | # Retrieve user info from API: 34 | users = admin_api.get_users() 35 | 36 | print(f'{"Username":^30} {"Last Login":^20} {"User Enrolled"}') 37 | print(f'{"=" * 30} {"=" * 20} {"=" * 15}') 38 | for user in users: 39 | line_out = f"{user['username']:30} " 40 | line_out += f"{human_time(user['last_login']):20} " 41 | line_out += f" {user['is_enrolled']} " 42 | print(line_out) 43 | -------------------------------------------------------------------------------- /examples/Admin/report_user_by_email.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ Script to illustrate how to retrieve a user from the Duo Admin API using the associated email address""" 4 | 5 | import sys 6 | import getpass 7 | 8 | import duo_client 9 | 10 | argv_iter = iter(sys.argv[1:]) 11 | 12 | 13 | def get_next_arg(prompt, secure=False): 14 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 15 | try: 16 | return next(argv_iter) 17 | except StopIteration: 18 | if secure is True: 19 | return getpass.getpass(prompt) 20 | else: 21 | return input(prompt) 22 | 23 | 24 | def main(): 25 | """ Primary script execution code """ 26 | # Configuration and information about objects to create. 27 | admin_api = duo_client.Admin( 28 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 29 | skey=get_next_arg('integration secret key: ', secure=True), 30 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 31 | ) 32 | 33 | # Retrieve user info from API: 34 | email_address = get_next_arg('E-mail address of user to retrieve: ') 35 | user = admin_api.get_user_by_email(email_address) 36 | 37 | if user: 38 | print(user) 39 | else: 40 | print(f"User with email [{email_address}] could not be found.") 41 | 42 | 43 | if __name__ == '__main__': 44 | main() 45 | -------------------------------------------------------------------------------- /examples/Admin/report_users_and_phones.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import csv 3 | import sys 4 | 5 | import duo_client 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | def get_next_arg(prompt): 9 | try: 10 | return next(argv_iter) 11 | except StopIteration: 12 | return input(prompt) 13 | 14 | # Configuration and information about objects to create. 15 | admin_api = duo_client.Admin( 16 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 17 | skey=get_next_arg('integration secret key: '), 18 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 19 | ) 20 | 21 | # Retrieve user info from API: 22 | users = admin_api.get_users() 23 | 24 | # Print CSV of username, phone number, phone type, and phone platform: 25 | # 26 | # (If a user has multiple phones, there will be one line printed per 27 | # associated phone.) 28 | reporter = csv.writer(sys.stdout) 29 | print("[+] Report of all users and associated phones:") 30 | reporter.writerow(('Username', 'Phone Number', 'Type', 'Platform')) 31 | for user in users: 32 | for phone in user["phones"]: 33 | reporter.writerow([ 34 | user["username"], 35 | phone["number"], 36 | phone["type"], 37 | phone["platform"], 38 | ]) 39 | -------------------------------------------------------------------------------- /examples/Admin/trust_monitor_events.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Print Duo Trust Monitor Events which surfaced within the past two weeks.""" 3 | 4 | import json 5 | import sys 6 | from datetime import datetime, timedelta, timezone 7 | 8 | from duo_client import Admin 9 | 10 | argv_iter = iter(sys.argv[1:]) 11 | def get_next_arg(prompt): 12 | try: 13 | return next(argv_iter) 14 | except StopIteration: 15 | return input(prompt) 16 | 17 | 18 | def main(args): 19 | # Instantiate the Admin client object. 20 | admin_client = Admin(args[0], args[1], args[2]) 21 | 22 | # Query for Duo Trust Monitor events that were surfaced within the last two weeks (from today). 23 | now = datetime.now(tz=timezone.utc) 24 | mintime_ms = int((now - timedelta(weeks=2)).timestamp() * 1000) 25 | maxtime_ms = int(now.timestamp() * 1000) 26 | 27 | # Loop over the returned iterator to navigate through each event, printing it to stdout. 28 | for event in admin_client.get_trust_monitor_events_iterator(mintime_ms, maxtime_ms): 29 | print(json.dumps(event, sort_keys=True)) 30 | 31 | 32 | def parse_args(): 33 | ikey=get_next_arg('Duo Admin API integration key ("DI..."): ') 34 | skey=get_next_arg('Duo Admin API integration secret key: ') 35 | host=get_next_arg('Duo Admin API hostname ("api-....duosecurity.com"): ') 36 | return (ikey, skey, host,) 37 | 38 | 39 | if __name__ == "__main__": 40 | args = parse_args() 41 | main(args) 42 | -------------------------------------------------------------------------------- /examples/Admin/update_admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import pprint 3 | import sys 4 | 5 | import duo_client 6 | 7 | argv_iter = iter(sys.argv[1:]) 8 | def get_next_arg(prompt): 9 | try: 10 | return next(argv_iter) 11 | except StopIteration: 12 | return input(prompt) 13 | 14 | # Configuration and information about objects to create. 15 | admin_api = duo_client.Admin( 16 | ikey=get_next_arg('Admin API integration key ("DI..."): '), 17 | skey=get_next_arg('integration secret key: '), 18 | host=get_next_arg('API hostname ("api-....duosecurity.com"): '), 19 | ) 20 | 21 | ADMIN_ID = get_next_arg('Admin Id: ') 22 | NAME = get_next_arg('Admin name: ') or None 23 | PHONE = get_next_arg('Phone number (e.g. 2154567890): ') or None 24 | PASSWORD_CHANGE_REQ = get_next_arg('password_change_required: ') or None 25 | STATUS = get_next_arg('status: ') or None 26 | ROLE = get_next_arg('Administrative Role(Optional): ') or None 27 | SUBACCOUNT_ROLE = get_next_arg('Subaccount Role(Optional): ') or None 28 | 29 | updated_admin = admin_api.update_admin(admin_id=ADMIN_ID, 30 | name=NAME, 31 | phone=PHONE, 32 | password_change_required=PASSWORD_CHANGE_REQ, 33 | status=STATUS, 34 | role=ROLE, 35 | subaccount_role=SUBACCOUNT_ROLE) 36 | print('Updated Admin: ') 37 | pprint.pprint(updated_admin) 38 | -------------------------------------------------------------------------------- /examples/Admin/update_phone_names.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script to pull list of all phones and modify the name of each 3 | """ 4 | 5 | import sys 6 | import getpass 7 | 8 | import duo_client 9 | 10 | argv_iter = iter(sys.argv[1:]) 11 | 12 | 13 | def _get_next_arg(prompt, secure=False): 14 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 15 | try: 16 | return next(argv_iter) 17 | except StopIteration: 18 | if secure is True: 19 | return getpass.getpass(prompt) 20 | else: 21 | return input(prompt) 22 | 23 | 24 | admin_api = duo_client.Admin( 25 | ikey=_get_next_arg('Admin API integration key ("DI..."): '), 26 | skey=_get_next_arg('integration secret key: ', secure=True), 27 | host=_get_next_arg('API hostname ("api-....duosecurity.com"): '), 28 | ) 29 | 30 | phones = admin_api.get_phones() 31 | 32 | for phone in phones: 33 | print(f"Current phone name for device ID {phone['phone_id']} is {phone['name']}") 34 | new_phone_name = phone['name'] + '_new' 35 | print(f"Changing name to {new_phone_name}") 36 | result = admin_api.update_phone(phone_id=phone['phone_id'], name=new_phone_name) 37 | if result['name'] == new_phone_name: 38 | print(f"Device {phone['phone_id']} is now named {new_phone_name}.") 39 | else: 40 | print("An error occurred.") 41 | 42 | -------------------------------------------------------------------------------- /examples/Auth/README.md: -------------------------------------------------------------------------------- 1 | # Duo Auth API Examples Overview 2 | 3 | 4 | ## Examples 5 | 6 | This folder contains various examples to illustrate the usage of the `Auth` module within the 7 | `duo_client_python` library. The Duo Auth API is primarily intended for integrating user enrollment 8 | and authentication into a custom third-party application. The expectation is that the third-party 9 | application is providing the necessary user interface and supporting structure to complete primary 10 | authentication for users before calling the Duo Auth API for secure second factor authentication. 11 | 12 | These examples use console/tty based interactions to collect necessary information to provide fully 13 | functional interactions with the Duo Auth API. 14 | 15 | # Using 16 | 17 | To run an example query, execute a command like the following from the repo root: 18 | ```python 19 | $ python3 examples/Auth/basic_user_mfa.py 20 | ``` 21 | 22 | Or, from within this folder: 23 | ```python 24 | $ python3 basic_user_mfa.py 25 | ``` 26 | 27 | # Tested Against Python Versions 28 | * 3.7 29 | * 3.8 30 | * 3.9 31 | * 3.10 32 | * 3.11 33 | -------------------------------------------------------------------------------- /examples/Auth/async_advanced_user_mfa.log: -------------------------------------------------------------------------------- 1 | 2023-12-20 03:49:21,262 [INFO] async_advanced_user_mfa : _init_logger(96) - Logger created with file /Users/mtripod/code/duo_client_python/examples/Auth API/async_advanced_user_mfa.log at log level DEBUG 2 | 2023-12-20 03:49:21,262 [INFO] async_advanced_user_mfa : __init__(47) - ========== Starting async_advanced_user_mfa.py ========== 3 | 2023-12-20 03:49:21,710 [INFO] async_advanced_user_mfa : ping_duo(141) - Duo service check completed successfully. 4 | 2023-12-20 03:49:22,155 [INFO] async_advanced_user_mfa : verify_duo(151) - IKEY and SKEY provided have been verified. 5 | 2023-12-20 03:49:22,156 [INFO] async_advanced_user_mfa : _cleanup_authentications_dictionary(160) - #### Starting thread Auth-dict-cleanup #### 6 | 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : preauth_user_from_queue(193) - #### Starting thread Pre-auth-worker-0 #### 7 | 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : auth_user_from_queue(217) - #### Starting thread Auth-worker-0 #### 8 | 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : get_user_auth_result(242) - #### Starting thread Result-worker-0 #### 9 | 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : _cleanup_authentications_dictionary(163) - [Auth-dict-cleanup] Scanning for authentication data for older than 1703061862 10 | 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : preauth_user_from_queue(193) - #### Starting thread Pre-auth-worker-1 #### 11 | 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : auth_user_from_queue(217) - #### Starting thread Auth-worker-1 #### 12 | 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : get_user_auth_result(242) - #### Starting thread Result-worker-1 #### 13 | 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : preauth_user_from_queue(193) - #### Starting thread Pre-auth-worker-2 #### 14 | 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : auth_user_from_queue(217) - #### Starting thread Auth-worker-2 #### 15 | 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : get_user_auth_result(242) - #### Starting thread Result-worker-2 #### 16 | 2023-12-20 03:49:22,159 [DEBUG] async_advanced_user_mfa : prompt_for_username(184) - Prompting for username... 17 | 2023-12-20 03:49:29,220 [DEBUG] async_advanced_user_mfa : prompt_for_username(186) - Username: mark@duomark.net received 18 | 2023-12-20 03:49:29,220 [INFO] async_advanced_user_mfa : prompt_for_username(188) - mark@duomark.net placed in user_queue. 19 | 2023-12-20 03:49:29,282 [INFO] async_advanced_user_mfa : preauth_user_from_queue(204) - [Pre-auth-worker-1] Executing pre-authentication for mark@duomark.net... 20 | 2023-12-20 03:49:29,776 [INFO] async_advanced_user_mfa : preauth_user_from_queue(206) - [Pre-auth-worker-1] Pre-authentication result for mark@duomark.net is {'devices': [{'capabilities': ['auto', 'push', 'sms', 'mobile_otp'], 'device': 'DP80Z4GVU82OT8O1BNJF', 'display_name': 'Marx iPhone 13 (XXX-XXX-4788)', 'name': 'Marx iPhone 13', 'number': 'XXX-XXX-4788', 'type': 'phone'}, {'capabilities': ['sms'], 'device': 'DPQ5WJQR0ZI5TIIBL3BP', 'display_name': 'Google Voice (XXX-XXX-2752)', 'name': 'Google Voice', 'number': 'XXX-XXX-2752', 'type': 'phone'}, {'device': 'DHQOEFH4Q6KMAFU7Q8V4', 'name': '20361492', 'type': 'token'}], 'result': 'auth', 'status_msg': 'Account is active'} 21 | 2023-12-20 03:49:29,777 [INFO] async_advanced_user_mfa : preauth_user_from_queue(212) - [Pre-auth-worker-1] RUNNING property set to False. Cleaning up... 22 | 2023-12-20 03:49:29,812 [INFO] async_advanced_user_mfa : auth_user_from_queue(229) - [Auth-worker-1] Executing asynchronous authentication action for mark@duomark.net... 23 | 2023-12-20 03:49:30,225 [DEBUG] async_advanced_user_mfa : prompt_for_username(184) - Prompting for username... 24 | 2023-12-20 03:49:30,718 [INFO] async_advanced_user_mfa : auth_user_from_queue(232) - [Auth-worker-1] Placing f50324f8-42ce-419d-8403-ed6b78e72d44 in result_queue for user mark@duomark.net 25 | 2023-12-20 03:49:30,718 [INFO] async_advanced_user_mfa : auth_user_from_queue(237) - [Auth-worker-1] RUNNING property set to False. Cleaning up... 26 | 2023-12-20 03:49:30,738 [INFO] async_advanced_user_mfa : get_user_auth_result(254) - [Result-worker-1] Getting authentication result for TXID f50324f8-42ce-419d-8403-ed6b78e72d44, username mark@duomark.net... 27 | 2023-12-20 03:49:30,740 [INFO] async_advanced_user_mfa : get_user_auth_result(257) - [Result-worker-1] Waiting for mark@duomark.net to respond [f50324f8-42ce-419d-8403-ed6b78e72d44]... 28 | 2023-12-20 03:49:31,170 [INFO] async_advanced_user_mfa : get_user_auth_result(276) - [Result-worker-1] Still waiting for mark@duomark.net to respond [{'waiting': True, 'success': False, 'status': 'pushed', 'status_msg': 'Pushed a login request to your device...'}] 29 | 2023-12-20 03:49:31,170 [INFO] async_advanced_user_mfa : get_user_auth_result(257) - [Result-worker-1] Waiting for mark@duomark.net to respond [f50324f8-42ce-419d-8403-ed6b78e72d44]... 30 | 2023-12-20 03:49:34,097 [INFO] async_advanced_user_mfa : get_user_auth_result(261) - [Result-worker-1] Authentication result for mark@duomark.net [f50324f8-42ce-419d-8403-ed6b78e72d44] is {'waiting': False, 'success': True, 'status': 'allow', 'status_msg': 'Success. Logging you in...'} 31 | 2023-12-20 03:49:34,098 [INFO] async_advanced_user_mfa : get_user_auth_result(278) - [Result-worker-1] RUNNING property set to False. Cleaning up... 32 | 2023-12-20 03:49:52,163 [INFO] async_advanced_user_mfa : _cleanup_authentications_dictionary(163) - [Auth-dict-cleanup] Scanning for authentication data for older than 1703061892 33 | 2023-12-20 03:50:13,970 [DEBUG] async_advanced_user_mfa : close(113) - Signal number 2 received. 34 | 2023-12-20 03:50:13,971 [DEBUG] async_advanced_user_mfa : close(114) - Frame traceback: None 35 | 2023-12-20 03:50:13,971 [INFO] async_advanced_user_mfa : close(115) - SIGINIT received. Waiting for threads to complete... 36 | 2023-12-20 03:50:13,971 [INFO] async_advanced_user_mfa : close(117) - Setting instance RUNNING property to False... 37 | 2023-12-20 03:50:13,971 [INFO] async_advanced_user_mfa : close(121) - Waiting for Auth-dict-cleanup thread to complete... 38 | 2023-12-20 03:50:13,973 [INFO] async_advanced_user_mfa : auth_user_from_queue(237) - [Auth-worker-0] RUNNING property set to False. Cleaning up... 39 | 2023-12-20 03:50:13,979 [INFO] async_advanced_user_mfa : auth_user_from_queue(237) - [Auth-worker-2] RUNNING property set to False. Cleaning up... 40 | 2023-12-20 03:50:14,005 [INFO] async_advanced_user_mfa : get_user_auth_result(278) - [Result-worker-2] RUNNING property set to False. Cleaning up... 41 | 2023-12-20 03:50:14,006 [INFO] async_advanced_user_mfa : preauth_user_from_queue(212) - [Pre-auth-worker-0] RUNNING property set to False. Cleaning up... 42 | 2023-12-20 03:50:14,011 [INFO] async_advanced_user_mfa : get_user_auth_result(278) - [Result-worker-0] RUNNING property set to False. Cleaning up... 43 | 2023-12-20 03:50:14,070 [INFO] async_advanced_user_mfa : preauth_user_from_queue(212) - [Pre-auth-worker-2] RUNNING property set to False. Cleaning up... 44 | 2023-12-20 03:50:22,169 [INFO] async_advanced_user_mfa : _cleanup_authentications_dictionary(173) - [Auth-dict-cleanup] RUNNING property set to False. Cleaning up... 45 | 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Pre-auth-worker-0 thread to complete... 46 | 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Auth-worker-0 thread to complete... 47 | 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Result-worker-0 thread to complete... 48 | 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Pre-auth-worker-2 thread to complete... 49 | 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Auth-worker-2 thread to complete... 50 | 2023-12-20 03:50:22,173 [INFO] async_advanced_user_mfa : close(121) - Waiting for Result-worker-2 thread to complete... 51 | 2023-12-20 03:50:22,173 [INFO] async_advanced_user_mfa : close(127) - All threads complete. Shutting down. 52 | -------------------------------------------------------------------------------- /examples/Auth/async_basic_user_mfa.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Auth API user authentication using asynchronous resquest/response methods 3 | """ 4 | 5 | import duo_client 6 | import sys 7 | import getpass 8 | 9 | 10 | argv_iter = iter(sys.argv[1:]) 11 | 12 | 13 | def _get_next_arg(prompt, secure=False): 14 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 15 | try: 16 | return next(argv_iter) 17 | except StopIteration: 18 | if secure is True: 19 | return getpass.getpass(prompt) 20 | else: 21 | return input(prompt) 22 | 23 | 24 | def prompt_for_credentials() -> dict: 25 | """Collect required API credentials from command line prompts 26 | 27 | :return: dictionary containing Duo Auth API ikey, skey and hostname strings 28 | """ 29 | 30 | ikey = _get_next_arg('Duo Auth API integration key ("DI..."): ') 31 | skey = _get_next_arg('Duo Auth API integration secret key: ', secure=True) 32 | host = _get_next_arg('Duo Auth API hostname ("api-....duosecurity.com"): ') 33 | username = _get_next_arg('Duo Username: ') 34 | 35 | return {"USERNAME": username, "IKEY": ikey, "SKEY": skey, "APIHOST": host} 36 | 37 | 38 | def main(): 39 | """Main program entry point""" 40 | 41 | inputs = prompt_for_credentials() 42 | 43 | auth_client = duo_client.Auth( 44 | ikey=inputs['IKEY'], 45 | skey=inputs['SKEY'], 46 | host=inputs['APIHOST'] 47 | ) 48 | 49 | # Verify that the Duo service is available 50 | duo_ping = auth_client.ping() 51 | if 'time' in duo_ping: 52 | print("\nDuo service check completed successfully.") 53 | else: 54 | print(f"Error: {duo_ping}") 55 | 56 | # Verify that IKEY and SKEY information provided are valid 57 | duo_check= auth_client.check() 58 | if 'time' in duo_check: 59 | print("IKEY and SKEY provided have been verified.") 60 | else: 61 | print(f"Error: {duo_check}") 62 | 63 | # Execute pre-authentication for given user 64 | print(f"\nExecuting pre-authentication for {inputs['USERNAME']}...") 65 | pre_auth = auth_client.preauth(username=inputs['USERNAME']) 66 | 67 | if pre_auth['result'] == "auth": 68 | try: 69 | print(f"Executing authentication action for {inputs['USERNAME']}...") 70 | auth = auth_client.auth(factor="push", username=inputs['USERNAME'], device="auto", async_txn=True) 71 | if 'txid' in auth: 72 | waiting = True 73 | # Collect the authentication result 74 | print("Getting authentication result...") 75 | # Repeat long polling for async authentication status until no longer in a 'waiting' state 76 | while waiting is True: 77 | # Poll Duo Auth API for the status of the async authentication based upon transaction ID 78 | auth_status = auth_client.auth_status(auth['txid']) 79 | print(f"Auth status: {auth_status}") 80 | if auth_status['waiting'] is not True: 81 | # Waiting for response too async authentication is no longer 'True', so break the loop 82 | waiting = False 83 | # Parse response for the 'status' dictionary key to determine whether to allow or deny 84 | print(auth_status) 85 | else: 86 | # Some kind of unexpected error occurred 87 | print(f"Error: an unknown error occurred attempting authentication for [{inputs['USERNAME']}]") 88 | except Exception as e_str: 89 | print(e_str) 90 | else: 91 | print(pre_auth['status_msg']) 92 | 93 | 94 | if __name__ == '__main__': 95 | main() 96 | -------------------------------------------------------------------------------- /examples/Auth/basic_user_mfa.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Auth API uaer authentication with synchronous request/response 3 | """ 4 | 5 | import duo_client 6 | import sys 7 | import getpass 8 | 9 | from pprint import pprint 10 | 11 | 12 | argv_iter = iter(sys.argv[1:]) 13 | 14 | 15 | def _get_next_arg(prompt, secure=False): 16 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 17 | try: 18 | return next(argv_iter) 19 | except StopIteration: 20 | if secure is True: 21 | return getpass.getpass(prompt) 22 | else: 23 | return input(prompt) 24 | 25 | 26 | def prompt_for_credentials() -> dict: 27 | """Collect required API credentials from command line prompts 28 | 29 | :return: dictionary containing Duo Auth API ikey, skey and hostname strings 30 | """ 31 | 32 | ikey = _get_next_arg('Duo Auth API integration key ("DI..."): ') 33 | skey = _get_next_arg('Duo Auth API integration secret key: ', secure=True) 34 | host = _get_next_arg('Duo Auth API hostname ("api-....duosecurity.com"): ') 35 | username = _get_next_arg('Duo Username: ') 36 | 37 | return {"USERNAME": username, "IKEY": ikey, "SKEY": skey, "APIHOST": host} 38 | 39 | 40 | def main(): 41 | """Main program entry point""" 42 | 43 | inputs = prompt_for_credentials() 44 | 45 | auth_client = duo_client.Auth( 46 | ikey=inputs['IKEY'], 47 | skey=inputs['SKEY'], 48 | host=inputs['APIHOST'] 49 | ) 50 | 51 | # Verify that the Duo service is available 52 | duo_ping = auth_client.ping() 53 | if 'time' in duo_ping: 54 | print("\nDuo service check completed successfully.") 55 | else: 56 | print(f"Error: {duo_ping}") 57 | 58 | # Verify that IKEY and SKEY information provided are valid 59 | duo_check= auth_client.check() 60 | if 'time' in duo_check: 61 | print("IKEY and SKEY provided have been verified.") 62 | else: 63 | print(f"Error: {duo_check}") 64 | 65 | # Execute pre-authentication for given user 66 | print(f"\nExecuting pre-authentication for {inputs['USERNAME']}...") 67 | pre_auth = auth_client.preauth(username=inputs['USERNAME']) 68 | 69 | if pre_auth['result'] == "auth": 70 | try: 71 | # User exists and has an MFA device enrolled 72 | print(f"Executing authentication action for {inputs['USERNAME']}...") 73 | # "auto" is selected for the factor in this example, however the pre_auth['devices'] dictionary 74 | # element contains a list of factors available for the provided user, if an alternate method is desired 75 | auth = auth_client.auth(factor="auto", username=inputs['USERNAME'], device="auto") 76 | print(f"\n{auth['status_msg']}") 77 | except Exception as e_str: 78 | print(e_str) 79 | elif pre_auth['result'] == "allow": 80 | # User is in bypass mode 81 | print(pre_auth['status_msg']) 82 | elif pre_auth['result'] == "enroll": 83 | # User is unknown and not enrolled in Duo with a 'New User' policy setting of 'Require Enrollment' 84 | # Setting a 'New User' policy to 'Require Enrollment' should only be done for Group level policies where 85 | # the intent is to capture "partially enrolled" users. "Parially enrolled" users are those that Duo has a 86 | # defined username but does not have an MFA device enrolled. 87 | print("Please enroll in Duo using the following URL.") 88 | print(pre_auth['enroll_portal_url']) 89 | elif pre_auth['result'] == "deny": 90 | # User is denied by policy setting 91 | print(pre_auth['status_msg']) 92 | else: 93 | print("Error: an unexpected error occurred") 94 | print(pre_auth) 95 | 96 | 97 | if __name__ == '__main__': 98 | main() 99 | -------------------------------------------------------------------------------- /examples/Auth/basic_user_mfa_token.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of Duo Auth API uaer authentication with synchronous request/response using an assigned token 3 | as the MFA factor 4 | """ 5 | 6 | import duo_client 7 | import sys 8 | import getpass 9 | 10 | from pprint import pprint 11 | 12 | 13 | argv_iter = iter(sys.argv[1:]) 14 | 15 | 16 | def _get_next_arg(prompt, secure=False): 17 | """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" 18 | try: 19 | return next(argv_iter) 20 | except StopIteration: 21 | if secure is True: 22 | return getpass.getpass(prompt) 23 | else: 24 | return input(prompt) 25 | 26 | 27 | def prompt_for_credentials() -> dict: 28 | """Collect required API credentials from command line prompts 29 | 30 | :return: dictionary containing Duo Auth API ikey, skey and hostname strings 31 | """ 32 | 33 | ikey = _get_next_arg('Duo Auth API integration key ("DI..."): ') 34 | skey = _get_next_arg('Duo Auth API integration secret key: ', secure=True) 35 | host = _get_next_arg('Duo Auth API hostname ("api-....duosecurity.com"): ') 36 | username = _get_next_arg('Duo Username: ') 37 | 38 | return {"USERNAME": username, "IKEY": ikey, "SKEY": skey, "APIHOST": host} 39 | 40 | 41 | def main(): 42 | """Main program entry point""" 43 | 44 | inputs = prompt_for_credentials() 45 | 46 | auth_client = duo_client.Auth( 47 | ikey=inputs['IKEY'], 48 | skey=inputs['SKEY'], 49 | host=inputs['APIHOST'] 50 | ) 51 | 52 | # Verify that the Duo service is available 53 | duo_ping = auth_client.ping() 54 | if 'time' in duo_ping: 55 | print("\nDuo service check completed successfully.") 56 | else: 57 | print(f"Error: {duo_ping}") 58 | 59 | # Verify that IKEY and SKEY information provided are valid 60 | duo_check= auth_client.check() 61 | if 'time' in duo_check: 62 | print("IKEY and SKEY provided have been verified.") 63 | else: 64 | print(f"Error: {duo_check}") 65 | 66 | # Execute pre-authentication for given user 67 | print(f"\nExecuting pre-authentication for {inputs['USERNAME']}...") 68 | pre_auth = auth_client.preauth(username=inputs['USERNAME']) 69 | 70 | print("\n" + "=" * 30) 71 | pprint(f"Pre-Auth result: {pre_auth}") 72 | print("=" * 30 + "\n") 73 | 74 | for device in pre_auth['devices']: 75 | pprint(device) 76 | print() 77 | 78 | if pre_auth['result'] == "auth": 79 | try: 80 | print(f"Executing authentication action for {inputs['USERNAME']}...") 81 | # Prompt for the hardware token passcode 82 | passcode = _get_next_arg('Duo token passcode: ') 83 | auth = auth_client.auth(factor="passcode", username=inputs['USERNAME'], passcode=passcode) 84 | print(f"\n{auth['status_msg']}") 85 | except Exception as e_str: 86 | print(e_str) 87 | else: 88 | print(pre_auth) 89 | 90 | 91 | if __name__ == '__main__': 92 | main() 93 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Duo API Examples Overview 2 | 3 | 4 | ## Examples 5 | 6 | This folder contains several sub-folders, each containing examples to illustrate the usage of the various Duo Security 7 | APIs. 8 | 9 | ------- 10 | ### Admin API 11 | The Duo Admin API provides access to endpoints that are primarily focused on Duo account operational tasks, such as: 12 | 13 | - User management 14 | - MFA device management 15 | - Integration management 16 | - Policy management 17 | - Log extractions 18 | 19 | ------- 20 | ### Auth API 21 | The Duo Auth API provides access to user enrollment and authentication services and is primarily intended for use by 22 | application developers that want to integration Duo MFA functionality into their applications. 23 | 24 | ------- 25 | ### Accounts API 26 | The Duo Accounts API provides access to Duo account management functionality and is primarily intended for use by 27 | Duo Managed Service Provider (MSP) partners. -------------------------------------------------------------------------------- /examples/splunk/duo.conf: -------------------------------------------------------------------------------- 1 | [duo] 2 | ; admin api integration key 3 | ikey = 4 | 5 | ; admin api secret key 6 | skey = 7 | 8 | ; api- 9 | host = 10 | 11 | ; HTTP proxy support 12 | ;http_proxy = http://host[:port] 13 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | nose2 2 | flake8 3 | pytz>=2022.1 4 | dlint 5 | freezegun 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_rpm] 2 | release=1%%{?dist} 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | import os.path 4 | 5 | import duo_client 6 | 7 | requirements_filename = os.path.join( 8 | os.path.dirname(os.path.abspath(__file__)), "requirements.txt" 9 | ) 10 | 11 | with open(requirements_filename) as fd: 12 | install_requires = [i.strip() for i in fd.readlines()] 13 | 14 | requirements_dev_filename = os.path.join( 15 | os.path.dirname(os.path.abspath(__file__)), "requirements-dev.txt" 16 | ) 17 | 18 | with open(requirements_dev_filename) as fd: 19 | tests_require = [i.strip() for i in fd.readlines()] 20 | 21 | long_description_filename = os.path.join( 22 | os.path.dirname(os.path.abspath(__file__)), "README.md" 23 | ) 24 | 25 | with open(long_description_filename) as fd: 26 | long_description = fd.read() 27 | 28 | setup( 29 | name="duo_client", 30 | version=duo_client.__version__, 31 | description="Reference client for Duo Security APIs", 32 | long_description=long_description, 33 | long_description_content_type="text/markdown", 34 | author="Duo Security, Inc.", 35 | author_email="support@duosecurity.com", 36 | url="https://github.com/duosecurity/duo_client_python", 37 | packages=["duo_client", "duo_client.logs"], 38 | package_data={"duo_client": ["ca_certs.pem"]}, 39 | license="BSD", 40 | classifiers=[ 41 | "Programming Language :: Python", 42 | "License :: OSI Approved :: BSD License", 43 | ], 44 | install_requires=install_requires, 45 | tests_require=tests_require, 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosecurity/duo_client_python/b3576230e2456226860519d8c07d9cd92ce3a76f/tests/__init__.py -------------------------------------------------------------------------------- /tests/accountAdmin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosecurity/duo_client_python/b3576230e2456226860519d8c07d9cd92ce3a76f/tests/accountAdmin/__init__.py -------------------------------------------------------------------------------- /tests/accountAdmin/base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | from .. import util 4 | import duo_client.admin 5 | 6 | 7 | class TestAccountAdmin(unittest.TestCase): 8 | 9 | def setUp(self): 10 | child_host = 'example2.com' 11 | kwargs = {'ikey': 'test_ikey', 'skey': 'test_skey', 'host': 'example.com'} 12 | 13 | patcher = patch("duo_client.admin.AccountAdmin.get_child_api_host") 14 | self.mock_child_host = patcher.start() 15 | self.mock_child_host.return_value = child_host 16 | self.addCleanup(patcher.stop) 17 | 18 | self.client = duo_client.admin.AccountAdmin( 19 | 'DA012345678901234567', **kwargs) 20 | 21 | # monkeypatch client's _connect() 22 | self.client._connect = lambda: util.MockHTTPConnection() 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /tests/accountAdmin/test_billing.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAccountAdmin 6 | 7 | 8 | class TestBilling(TestAccountAdmin): 9 | def test_get_billing_edition(self): 10 | """Test to get billing edition 11 | """ 12 | response = self.client.get_edition() 13 | uri, args = response['uri'].split('?') 14 | 15 | self.assertEqual(response['method'], 'GET') 16 | self.assertEqual(uri, '/admin/v1/billing/edition') 17 | self.assertEqual(util.params_to_dict(args), 18 | { 19 | 'account_id': [self.client.account_id], 20 | }) 21 | 22 | def test_set_business_billing_edition(self): 23 | """Test to set PLATFORM billing edition 24 | """ 25 | response = self.client.set_edition('PLATFORM') 26 | uri = response['uri'] 27 | 28 | self.assertEqual(response['method'], 'POST') 29 | self.assertEqual(uri, '/admin/v1/billing/edition') 30 | self.assertEqual(json.loads(response['body']), 31 | { 32 | 'edition': 'PLATFORM', 33 | 'account_id': self.client.account_id, 34 | }) 35 | 36 | def test_set_enterprise_billing_edition(self): 37 | """Test to set ENTERPRISE billing edition 38 | """ 39 | response = self.client.set_edition('ENTERPRISE') 40 | uri = response['uri'] 41 | 42 | self.assertEqual(response['method'], 'POST') 43 | self.assertEqual(uri, '/admin/v1/billing/edition') 44 | self.assertEqual(json.loads(response['body']), 45 | { 46 | 'edition': 'ENTERPRISE', 47 | 'account_id': self.client.account_id, 48 | }) 49 | 50 | def test_get_telephony_credits(self): 51 | """Test to get telephony credits 52 | """ 53 | response = self.client.get_telephony_credits() 54 | uri, args = response['uri'].split('?') 55 | 56 | self.assertEqual(response['method'], 'GET') 57 | self.assertEqual(uri, '/admin/v1/billing/telephony_credits') 58 | self.assertEqual(util.params_to_dict(args), 59 | { 60 | 'account_id': [self.client.account_id], 61 | }) 62 | 63 | def test_set_telephony_credits(self): 64 | """Test to set telephony credits 65 | """ 66 | response = self.client.set_telephony_credits(10) 67 | uri = response['uri'] 68 | 69 | self.assertEqual(response['method'], 'POST') 70 | self.assertEqual(uri, '/admin/v1/billing/telephony_credits') 71 | self.assertEqual(json.loads(response['body']), 72 | { 73 | 'credits': '10', 74 | 'account_id': self.client.account_id, 75 | }) 76 | -------------------------------------------------------------------------------- /tests/admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosecurity/duo_client_python/b3576230e2456226860519d8c07d9cd92ce3a76f/tests/admin/__init__.py -------------------------------------------------------------------------------- /tests/admin/base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from .. import util 3 | import duo_client.admin 4 | import os 5 | 6 | 7 | class TestAdmin(unittest.TestCase): 8 | 9 | TEST_RESOURCES_DIR = dir_path = os.path.join( 10 | os.path.abspath(os.curdir), 'tests', 'resources') 11 | 12 | def setUp(self): 13 | self.client = duo_client.admin.Admin( 14 | 'test_ikey', 'test_akey', 'example.com') 15 | self.client.account_id = 'DA012345678901234567' 16 | # monkeypatch client's _connect() 17 | self.client._connect = lambda: util.MockHTTPConnection() 18 | 19 | # if you are wanting to simulate getting lists of objects 20 | # rather than a single object 21 | self.client_list = duo_client.admin.Admin( 22 | 'test_ikey', 'test_akey', 'example.com') 23 | self.client_list.account_id = 'DA012345678901234567' 24 | self.client_list._connect = \ 25 | lambda: util.MockHTTPConnection(data_response_should_be_list=True) 26 | 27 | # if you are wanting to get a response from a call to get 28 | # authentication logs 29 | self.client_authlog = duo_client.admin.Admin( 30 | 'test_ikey', 'test_akey', 'example.com') 31 | self.client_authlog.account_id = 'DA012345678901234567' 32 | self.client_authlog._connect = \ 33 | lambda: util.MockHTTPConnection(data_response_from_get_authlog=True) 34 | 35 | # client to simulate basic structure of a call to fetch Duo Trust 36 | # Monitor events. 37 | self.client_dtm = duo_client.admin.Admin( 38 | 'test_ikey', 39 | 'test_akey', 40 | 'example.com', 41 | ) 42 | self.client_dtm.account_id = 'DA012345678901234567' 43 | self.client_dtm._connect = \ 44 | lambda: util.MockHTTPConnection(data_response_from_get_dtm_events=True) 45 | 46 | self.items_response_client = duo_client.admin.Admin( 47 | 'test_ikey', 'test_akey', 'example.com') 48 | self.items_response_client.account_id = 'DA012345678901234567' 49 | self.items_response_client._connect = \ 50 | lambda: util.MockHTTPConnection(data_response_from_get_items=True) 51 | 52 | 53 | if __name__ == '__main__': 54 | unittest.main() 55 | -------------------------------------------------------------------------------- /tests/admin/test_activity.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | from .base import TestAdmin 3 | from datetime import datetime, timedelta 4 | from freezegun import freeze_time 5 | import pytz 6 | 7 | 8 | class TestEndpoints(TestAdmin): 9 | 10 | def test_get_activity_log(self): 11 | """ Test to get activities log. 12 | """ 13 | response = self.items_response_client.get_activity_logs(maxtime='1663131599000', mintime='1662958799000') 14 | uri, args = response['uri'].split('?') 15 | 16 | self.assertEqual(response['method'], 'GET') 17 | self.assertEqual(uri, '/admin/v2/logs/activity') 18 | self.assertEqual( 19 | util.params_to_dict(args)['account_id'], 20 | [self.items_response_client.account_id]) 21 | 22 | @freeze_time('2022-10-01') 23 | def test_get_activity_log_with_no_args(self): 24 | freezed_time = datetime(2022,10,1,0,0,0, tzinfo=pytz.utc) 25 | expected_mintime = str(int((freezed_time-timedelta(days=180)).timestamp()*1000)) 26 | expected_maxtime = str(int(freezed_time.timestamp() * 1000)) 27 | response = self.items_response_client.get_activity_logs() 28 | uri, args = response['uri'].split('?') 29 | param_dict = util.params_to_dict(args) 30 | self.assertEqual(response['method'], 'GET') 31 | self.assertEqual(uri, '/admin/v2/logs/activity') 32 | self.assertEqual( 33 | param_dict['mintime'], 34 | [expected_mintime]) 35 | self.assertEqual( 36 | param_dict['maxtime'], 37 | [expected_maxtime]) 38 | -------------------------------------------------------------------------------- /tests/admin/test_administrative_units.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | from .base import TestAdmin 3 | 4 | 5 | class TestAdminUnits(TestAdmin): 6 | # Uses underlying paging 7 | def test_get_administratrive_units(self): 8 | response = self.client_list.get_administrative_units() 9 | response = response[0] 10 | self.assertEqual(response['method'], 'GET') 11 | (uri, args) = response['uri'].split('?') 12 | self.assertEqual(uri, '/admin/v1/administrative_units') 13 | 14 | def test_get_administrative_units_with_limit(self): 15 | response = self.client_list.get_administrative_units(limit=20) 16 | response = response[0] 17 | self.assertEqual(response['method'], 'GET') 18 | (uri, args) = response['uri'].split('?') 19 | self.assertEqual(uri, '/admin/v1/administrative_units') 20 | self.assertEqual( 21 | util.params_to_dict(args), 22 | { 23 | 'account_id': [self.client.account_id], 24 | 'limit': ['20'], 25 | 'offset': ['0'], 26 | }) 27 | 28 | def test_get_adminstrative_units_with_limit_offset(self): 29 | response = self.client_list.get_administrative_units(limit=20, offset=2) 30 | response = response[0] 31 | self.assertEqual(response['method'], 'GET') 32 | (uri, args) = response['uri'].split('?') 33 | self.assertEqual(uri, '/admin/v1/administrative_units') 34 | self.assertEqual( 35 | util.params_to_dict(args), 36 | { 37 | 'account_id': [self.client.account_id], 38 | 'limit': ['20'], 39 | 'offset': ['2'], 40 | }) 41 | 42 | def test_get_administrative_units_with_offset(self): 43 | response = self.client_list.get_administrative_units(offset=9001) 44 | response = response[0] 45 | self.assertEqual(response['method'], 'GET') 46 | (uri, args) = response['uri'].split('?') 47 | self.assertEqual(uri, '/admin/v1/administrative_units') 48 | self.assertEqual( 49 | util.params_to_dict(args), 50 | { 51 | 'account_id': [self.client.account_id], 52 | 'limit': ['100'], 53 | 'offset': ['0'], 54 | }) 55 | 56 | def test_get_administrative_units_iterator(self): 57 | expected_path = '/admin/v1/administrative_units' 58 | expected_method = 'GET' 59 | 60 | tests = [ 61 | { 62 | 'admin_id': 'aaaa', 63 | 'group_id': '1234', 64 | 'integration_key': 'aaaaaaaaaaaaaaaaaaaa', 65 | }, 66 | { 67 | 'admin_id': 'aaaa', 68 | 'group_id': '1234', 69 | }, 70 | { 71 | 'admin_id': 'aaaa', 72 | }, 73 | {} 74 | ] 75 | 76 | for test in tests: 77 | response = ( 78 | self.client_list.get_administrative_units_iterator(**test) 79 | ) 80 | response = next(response) 81 | self.assertEqual(response['method'], expected_method) 82 | (uri, args) = response['uri'].split('?') 83 | self.assertEqual(uri, expected_path) 84 | expected_params = { 85 | key: [value] for (key, value) in test.items() 86 | } 87 | expected_params.update( 88 | { 89 | 'account_id': [self.client.account_id], 90 | 'limit': ['100'], 91 | 'offset': ['0'], 92 | } 93 | ) 94 | self.assertEqual(util.params_to_dict(args), expected_params) 95 | -------------------------------------------------------------------------------- /tests/admin/test_authlog.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestEndpoints(TestAdmin): 7 | def test_get_authentication_log_v1(self): 8 | """ Test to get authentication log on version 1 api. 9 | """ 10 | response = self.client_list.get_authentication_log(api_version=1)[0] 11 | uri, args = response['uri'].split('?') 12 | 13 | self.assertEqual(response['method'], 'GET') 14 | self.assertEqual(uri, '/admin/v1/logs/authentication') 15 | self.assertEqual( 16 | util.params_to_dict(args)['account_id'], 17 | [self.client_list.account_id]) 18 | 19 | def test_get_authentication_log_v2(self): 20 | """ Test to get authentication log on version 1 api. 21 | """ 22 | response = self.client_authlog.get_authentication_log(api_version=2) 23 | uri, args = response['uri'].split('?') 24 | 25 | self.assertEqual(response['method'], 'GET') 26 | self.assertEqual(uri, '/admin/v2/logs/authentication') 27 | self.assertEqual( 28 | util.params_to_dict(args)['account_id'], 29 | [self.client_authlog.account_id]) 30 | -------------------------------------------------------------------------------- /tests/admin/test_bypass_codes.py: -------------------------------------------------------------------------------- 1 | import duo_client.admin 2 | from .. import util 3 | from .base import TestAdmin 4 | 5 | 6 | class TestBypassCodes(TestAdmin): 7 | def test_delete_bypass_code_by_id(self): 8 | """ Test to delete a bypass code by id. 9 | """ 10 | response = self.client.delete_bypass_code_by_id('DU012345678901234567') 11 | uri, args = response['uri'].split('?') 12 | 13 | self.assertEqual(response['method'], 'DELETE') 14 | self.assertEqual(uri, '/admin/v1/bypass_codes/DU012345678901234567') 15 | self.assertEqual(util.params_to_dict(args), 16 | {'account_id': [self.client.account_id]}) 17 | 18 | def test_get_bypass_codes_generator(self): 19 | """ Test to get bypass codes generator. 20 | """ 21 | generator = self.client_list.get_bypass_codes_generator() 22 | response = next(generator) 23 | uri, args = response['uri'].split('?') 24 | 25 | self.assertEqual(response['method'], 'GET') 26 | self.assertEqual(uri, '/admin/v1/bypass_codes') 27 | self.assertEqual( 28 | util.params_to_dict(args), 29 | { 30 | 'account_id': [self.client_list.account_id], 31 | 'limit': ['100'], 32 | 'offset': ['0'], 33 | }) 34 | 35 | def test_get_bypass_codes(self): 36 | """ Test to get bypass codes without params. 37 | """ 38 | response = self.client_list.get_bypass_codes()[0] 39 | uri, args = response['uri'].split('?') 40 | 41 | self.assertEqual(response['method'], 'GET') 42 | self.assertEqual(uri, '/admin/v1/bypass_codes') 43 | self.assertEqual( 44 | util.params_to_dict(args), 45 | { 46 | 'account_id': [self.client_list.account_id], 47 | 'limit': ['100'], 48 | 'offset': ['0'], 49 | }) 50 | 51 | def test_get_bypass_codes_limit(self): 52 | """ Test to get bypass codes with limit. 53 | """ 54 | response = self.client_list.get_bypass_codes(limit='20')[0] 55 | uri, args = response['uri'].split('?') 56 | 57 | self.assertEqual(response['method'], 'GET') 58 | self.assertEqual(uri, '/admin/v1/bypass_codes') 59 | self.assertEqual( 60 | util.params_to_dict(args), 61 | { 62 | 'account_id': [self.client_list.account_id], 63 | 'limit': ['20'], 64 | 'offset': ['0'], 65 | }) 66 | 67 | def test_get_bypass_codes_offset(self): 68 | """ Test to get bypass codes with offset. 69 | """ 70 | response = self.client_list.get_bypass_codes(offset='20')[0] 71 | uri, args = response['uri'].split('?') 72 | 73 | self.assertEqual(response['method'], 'GET') 74 | self.assertEqual(uri, '/admin/v1/bypass_codes') 75 | self.assertEqual( 76 | util.params_to_dict(args), 77 | { 78 | 'account_id': [self.client_list.account_id], 79 | 'limit': ['100'], 80 | 'offset': ['0'], 81 | }) 82 | 83 | def test_get_bypass_codes_limit_offset(self): 84 | """ Test to get bypass codes with limit and offset. 85 | """ 86 | response = self.client_list.get_bypass_codes(limit='20', offset='2')[0] 87 | uri, args = response['uri'].split('?') 88 | 89 | self.assertEqual(response['method'], 'GET') 90 | self.assertEqual(uri, '/admin/v1/bypass_codes') 91 | self.assertEqual( 92 | util.params_to_dict(args), 93 | { 94 | 'account_id': [self.client_list.account_id], 95 | 'limit': ['20'], 96 | 'offset': ['2'], 97 | }) 98 | -------------------------------------------------------------------------------- /tests/admin/test_desktop_tokens.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestDesktopTokens(TestAdmin): 7 | def test_get_desktoptokens_generator(self): 8 | """ Test to get desktop tokens generator. 9 | """ 10 | generator = self.client_list.get_desktoptokens_generator() 11 | response = next(generator) 12 | uri, args = response['uri'].split('?') 13 | 14 | self.assertEqual(response['method'], 'GET') 15 | self.assertEqual(uri, '/admin/v1/desktoptokens') 16 | self.assertEqual( 17 | util.params_to_dict(args), 18 | { 19 | 'account_id': [self.client_list.account_id], 20 | 'limit': ['100'], 21 | 'offset': ['0'], 22 | }) 23 | 24 | def test_get_desktoptokens(self): 25 | """ Test to get desktop tokens without params. 26 | """ 27 | response = self.client_list.get_desktoptokens()[0] 28 | uri, args = response['uri'].split('?') 29 | 30 | self.assertEqual(response['method'], 'GET') 31 | self.assertEqual(uri, '/admin/v1/desktoptokens') 32 | self.assertEqual( 33 | util.params_to_dict(args), 34 | { 35 | 'account_id': [self.client_list.account_id], 36 | 'limit': ['100'], 37 | 'offset': ['0'], 38 | }) 39 | 40 | def test_get_desktoptokens_limit(self): 41 | """ Test to get desktop tokens with limit. 42 | """ 43 | response = self.client_list.get_desktoptokens(limit='20')[0] 44 | uri, args = response['uri'].split('?') 45 | 46 | self.assertEqual(response['method'], 'GET') 47 | self.assertEqual(uri, '/admin/v1/desktoptokens') 48 | self.assertEqual( 49 | util.params_to_dict(args), 50 | { 51 | 'account_id': [self.client_list.account_id], 52 | 'limit': ['20'], 53 | 'offset': ['0'], 54 | }) 55 | 56 | def test_get_desktoptokens_offset(self): 57 | """ Test to get desktop tokens with offset. 58 | """ 59 | response = self.client_list.get_desktoptokens(offset='20')[0] 60 | uri, args = response['uri'].split('?') 61 | 62 | self.assertEqual(response['method'], 'GET') 63 | self.assertEqual(uri, '/admin/v1/desktoptokens') 64 | self.assertEqual( 65 | util.params_to_dict(args), 66 | { 67 | 'account_id': [self.client_list.account_id], 68 | 'limit': ['100'], 69 | 'offset': ['0'], 70 | }) 71 | 72 | def test_get_desktoptokens_limit_offset(self): 73 | """ Test to get desktop tokens with limit and offset. 74 | """ 75 | response = self.client_list.get_desktoptokens(limit='20', offset='2')[0] 76 | uri, args = response['uri'].split('?') 77 | 78 | self.assertEqual(response['method'], 'GET') 79 | self.assertEqual(uri, '/admin/v1/desktoptokens') 80 | self.assertEqual( 81 | util.params_to_dict(args), 82 | { 83 | 'account_id': [self.client_list.account_id], 84 | 'limit': ['20'], 85 | 'offset': ['2'], 86 | }) 87 | -------------------------------------------------------------------------------- /tests/admin/test_endpoints.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAdmin 6 | 7 | 8 | class TestEndpoints(TestAdmin): 9 | def test_get_endpoints_iterator(self): 10 | """ Test to get endpoints iterator 11 | """ 12 | iterator = self.client_list.get_endpoints_iterator() 13 | response = next(iterator) 14 | self.assertEqual(response['method'], 'GET') 15 | (uri, args) = response['uri'].split('?') 16 | self.assertEqual(uri, '/admin/v1/endpoints') 17 | self.assertEqual( 18 | util.params_to_dict(args), 19 | { 20 | 'account_id': [self.client.account_id], 21 | 'limit': ['100'], 22 | 'offset': ['0'], 23 | }) 24 | 25 | def test_get_endpoints(self): 26 | """ Test to get endpoints. 27 | """ 28 | response = self.client_list.get_endpoints()[0] 29 | self.assertEqual(response['method'], 'GET') 30 | (uri, args) = response['uri'].split('?') 31 | self.assertEqual(uri, '/admin/v1/endpoints') 32 | self.assertEqual( 33 | util.params_to_dict(args), 34 | { 35 | 'account_id': [self.client.account_id], 36 | 'limit': ['100'], 37 | 'offset': ['0'], 38 | }) 39 | 40 | def test_get_endpoints_offset(self): 41 | """ Test to get endpoints with pagination params. 42 | """ 43 | response = self.client_list.get_endpoints(offset=20)[0] 44 | self.assertEqual(response['method'], 'GET') 45 | (uri, args) = response['uri'].split('?') 46 | self.assertEqual(uri, '/admin/v1/endpoints') 47 | self.assertEqual( 48 | util.params_to_dict(args), 49 | { 50 | 'account_id': [self.client.account_id], 51 | 'limit': ['100'], 52 | 'offset': ['0'], 53 | }) 54 | 55 | def test_get_endpoints_limit(self): 56 | """ Test to get endpoints with pagination params. 57 | """ 58 | response = self.client_list.get_endpoints(limit=20)[0] 59 | self.assertEqual(response['method'], 'GET') 60 | (uri, args) = response['uri'].split('?') 61 | self.assertEqual(uri, '/admin/v1/endpoints') 62 | self.assertEqual( 63 | util.params_to_dict(args), 64 | { 65 | 'account_id': [self.client.account_id], 66 | 'limit': ['20'], 67 | 'offset': ['0'], 68 | }) 69 | 70 | def test_get_endpoints_limit_and_offset(self): 71 | """ Test to get endpoints with pagination params. 72 | """ 73 | response = self.client_list.get_endpoints(limit=35, offset=20)[0] 74 | self.assertEqual(response['method'], 'GET') 75 | (uri, args) = response['uri'].split('?') 76 | self.assertEqual(uri, '/admin/v1/endpoints') 77 | self.assertEqual( 78 | util.params_to_dict(args), 79 | { 80 | 'account_id': [self.client.account_id], 81 | 'limit': ['35'], 82 | 'offset': ['20'], 83 | }) 84 | 85 | def test_get_endpoint(self): 86 | """ Test getting a single endpoint. 87 | """ 88 | epkey = 'EP18JX1A10AB102M2T2X' 89 | response = self.client_list.get_endpoint(epkey)[0] 90 | (uri, args) = response['uri'].split('?') 91 | self.assertEqual(response['method'], 'GET') 92 | self.assertEqual(uri, '/admin/v1/endpoints/' + epkey) 93 | self.assertEqual( 94 | util.params_to_dict(args), 95 | { 96 | 'account_id': [self.client.account_id], 97 | }) 98 | 99 | if __name__ == '__main__': 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /tests/admin/test_integration.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import json 5 | import duo_client.admin 6 | from .base import TestAdmin 7 | 8 | 9 | class TestIntegration(TestAdmin): 10 | def setUp(self): 11 | super(TestIntegration, self).setUp() 12 | self.integration_key = "DISRYL7L8LZ5YXNWKGNK" 13 | 14 | def test_get_integration(self): 15 | response = self.client.get_integration(self.integration_key) 16 | (uri, args) = response['uri'].split('?') 17 | 18 | self.assertEqual(response['method'], 'GET') 19 | self.assertEqual(uri, '/admin/v3/integrations/{}'.format(self.integration_key)) 20 | self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) 21 | 22 | def test_delete_integration(self): 23 | response = self.client.delete_integration(self.integration_key) 24 | (uri, args) = response['uri'].split('?') 25 | 26 | self.assertEqual(response['method'], 'DELETE') 27 | self.assertEqual(uri, '/admin/v3/integrations/{}'.format(self.integration_key)) 28 | self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) 29 | 30 | def test_create_integration(self): 31 | response = self.client.create_integration( 32 | name="New integration name", 33 | integration_type="sso-generic", 34 | sso={ 35 | "idp_metadata": None, 36 | "saml_config": {} 37 | }, 38 | ) 39 | 40 | self.assertEqual(response['method'], 'POST') 41 | self.assertEqual(response['uri'], '/admin/v3/integrations') 42 | self.assertEqual(json.loads(response['body']), 43 | { 44 | "account_id": self.client.account_id, 45 | "name": "New integration name", 46 | "type": "sso-generic", 47 | "sso": { 48 | "idp_metadata": None, 49 | "saml_config": {} 50 | }, 51 | } 52 | ) 53 | 54 | def test_update_integration_success(self): 55 | response = self.client.update_integration( 56 | self.integration_key, 57 | name="Integration name", 58 | sso={ 59 | "saml_config": { 60 | "nameid_attribute": "mail", 61 | } 62 | }, 63 | ) 64 | 65 | self.assertEqual(response['method'], 'POST') 66 | self.assertEqual(response['uri'], '/admin/v3/integrations/{}'.format(self.integration_key)) 67 | self.assertEqual(json.loads(response['body']), 68 | { 69 | "account_id": self.client.account_id, 70 | "name": "Integration name", 71 | "sso": { 72 | "saml_config": { 73 | "nameid_attribute": "mail", 74 | } 75 | }, 76 | } 77 | ) 78 | 79 | if __name__ == '__main__': 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /tests/admin/test_integrations.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestIntegrations(TestAdmin): 7 | def test_get_integrations_generator(self): 8 | """ Test to get integrations generator. 9 | """ 10 | generator = self.client_list.get_integrations_generator() 11 | response = next(generator) 12 | self.assertEqual(response['method'], 'GET') 13 | (uri, args) = response['uri'].split('?') 14 | self.assertEqual(uri, '/admin/v3/integrations') 15 | self.assertEqual( 16 | util.params_to_dict(args), 17 | { 18 | 'account_id': [self.client.account_id], 19 | 'limit': ['100'], 20 | 'offset': ['0'], 21 | }) 22 | 23 | def test_get_integrations(self): 24 | """ Test to get integrations without pagination params. 25 | """ 26 | response = self.client_list.get_integrations() 27 | response = response[0] 28 | self.assertEqual(response['method'], 'GET') 29 | (uri, args) = response['uri'].split('?') 30 | self.assertEqual(uri, '/admin/v3/integrations') 31 | self.assertEqual( 32 | util.params_to_dict(args), 33 | { 34 | 'account_id': [self.client.account_id], 35 | 'limit': ['100'], 36 | 'offset': ['0'], 37 | }) 38 | 39 | def test_get_integrations_with_limit(self): 40 | """ Test to get integrations with pagination params. 41 | """ 42 | response = self.client_list.get_integrations(limit=20) 43 | response = response[0] 44 | self.assertEqual(response['method'], 'GET') 45 | (uri, args) = response['uri'].split('?') 46 | self.assertEqual(uri, '/admin/v3/integrations') 47 | self.assertEqual( 48 | util.params_to_dict(args), 49 | { 50 | 'account_id': [self.client.account_id], 51 | 'limit': ['20'], 52 | 'offset': ['0'], 53 | }) 54 | 55 | def test_get_integrations_with_limit_offset(self): 56 | """ Test to get integrations with pagination params. 57 | """ 58 | response = self.client_list.get_integrations(limit=20, offset=2) 59 | response = response[0] 60 | self.assertEqual(response['method'], 'GET') 61 | (uri, args) = response['uri'].split('?') 62 | self.assertEqual(uri, '/admin/v3/integrations') 63 | self.assertEqual( 64 | util.params_to_dict(args), 65 | { 66 | 'account_id': [self.client.account_id], 67 | 'limit': ['20'], 68 | 'offset': ['2'], 69 | }) 70 | 71 | def test_get_integrations_with_offset(self): 72 | """ Test to get integrations with pagination params. 73 | """ 74 | response = self.client_list.get_integrations(offset=9001) 75 | response = response[0] 76 | self.assertEqual(response['method'], 'GET') 77 | (uri, args) = response['uri'].split('?') 78 | self.assertEqual(uri, '/admin/v3/integrations') 79 | self.assertEqual( 80 | util.params_to_dict(args), 81 | { 82 | 'account_id': [self.client.account_id], 83 | 'limit': ['100'], 84 | 'offset': ['0'], 85 | }) 86 | -------------------------------------------------------------------------------- /tests/admin/test_logo.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .base import TestAdmin 3 | import os 4 | import base64 5 | import urllib.parse 6 | 7 | 8 | class TestLogo(TestAdmin): 9 | 10 | def test_update_logo(self): 11 | # Get an image to upload: 12 | logo_path = os.path.join(self.TEST_RESOURCES_DIR, "barn-owl-small.png") 13 | with open(logo_path, "rb") as image_file: 14 | logo_file = image_file.read() 15 | 16 | # Call function in client: 17 | response = self.client.update_logo(logo_file) 18 | 19 | # Prep validation text: 20 | base64_logo = base64.b64encode(logo_file) 21 | base64_logo = urllib.parse.quote_plus(base64_logo) 22 | 23 | # Validate response: 24 | self.assertTrue( 25 | json.loads(response['body']).get('logo'), 26 | base64_logo 27 | ) 28 | -------------------------------------------------------------------------------- /tests/admin/test_passport.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .base import TestAdmin 4 | from .. import util 5 | 6 | 7 | class TestPassport(TestAdmin): 8 | def test_get_passport(self): 9 | """ Test get passport configuration 10 | """ 11 | response = self.client.get_passport_config() 12 | (uri, args) = response['uri'].split('?') 13 | self.assertEqual(response['method'], 'GET') 14 | self.assertEqual(uri, '/admin/v2/passport/config') 15 | self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) 16 | 17 | def test_update_passport(self): 18 | """ Test update passport configuration 19 | """ 20 | response = self.client.update_passport_config(enabled_status="enabled-for-groups", enabled_groups=["passport-test-group"], custom_supported_browsers={"macos": [{"team_id": "UBF8T346G9"},], "windows": [{"common_name": "Duo Security LLC"},],}) 21 | self.assertEqual(response["uri"], "/admin/v2/passport/config") 22 | body = json.loads(response["body"]) 23 | self.assertEqual(body["enabled_status"], "enabled-for-groups") 24 | self.assertEqual(body["enabled_groups"], ["passport-test-group"]) 25 | self.assertEqual(body["disabled_groups"], None) 26 | self.assertEqual(body["custom_supported_browsers"], {"macos":[{"team_id":"UBF8T346G9"}],"windows":[{"common_name":"Duo Security LLC"}]}) 27 | -------------------------------------------------------------------------------- /tests/admin/test_phones.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestPhones(TestAdmin): 7 | def test_get_phones_generator(self): 8 | """ Test to get phones generator. 9 | """ 10 | generator = self.client_list.get_phones_generator() 11 | response = next(generator) 12 | self.assertEqual(response['method'], 'GET') 13 | (uri, args) = response['uri'].split('?') 14 | self.assertEqual(uri, '/admin/v1/phones') 15 | self.assertEqual( 16 | util.params_to_dict(args), 17 | { 18 | 'account_id': [self.client.account_id], 19 | 'limit': ['100'], 20 | 'offset': ['0'], 21 | }) 22 | 23 | def test_get_phones(self): 24 | """ Test to get phones without pagination params. 25 | """ 26 | response = self.client_list.get_phones() 27 | response = response[0] 28 | self.assertEqual(response['method'], 'GET') 29 | (uri, args) = response['uri'].split('?') 30 | self.assertEqual(uri, '/admin/v1/phones') 31 | self.assertEqual( 32 | util.params_to_dict(args), 33 | { 34 | 'account_id': [self.client.account_id], 35 | 'limit': ['100'], 36 | 'offset': ['0'], 37 | }) 38 | 39 | def test_get_phones_with_limit(self): 40 | """ Test to get phones with pagination params. 41 | """ 42 | response = self.client_list.get_phones(limit=20) 43 | response = response[0] 44 | self.assertEqual(response['method'], 'GET') 45 | (uri, args) = response['uri'].split('?') 46 | self.assertEqual(uri, '/admin/v1/phones') 47 | self.assertEqual( 48 | util.params_to_dict(args), 49 | { 50 | 'account_id': [self.client.account_id], 51 | 'limit': ['20'], 52 | 'offset': ['0'], 53 | }) 54 | 55 | def test_get_phones_with_limit_offset(self): 56 | """ Test to get phones with pagination params. 57 | """ 58 | response = self.client_list.get_phones(limit=20, offset=2) 59 | response = response[0] 60 | self.assertEqual(response['method'], 'GET') 61 | (uri, args) = response['uri'].split('?') 62 | self.assertEqual(uri, '/admin/v1/phones') 63 | self.assertEqual( 64 | util.params_to_dict(args), 65 | { 66 | 'account_id': [self.client.account_id], 67 | 'limit': ['20'], 68 | 'offset': ['2'], 69 | }) 70 | 71 | def test_get_phones_with_offset(self): 72 | """ Test to get phones with pagination params. 73 | """ 74 | response = self.client_list.get_phones(offset=9001) 75 | response = response[0] 76 | self.assertEqual(response['method'], 'GET') 77 | (uri, args) = response['uri'].split('?') 78 | self.assertEqual(uri, '/admin/v1/phones') 79 | self.assertEqual( 80 | util.params_to_dict(args), 81 | { 82 | 'account_id': [self.client.account_id], 83 | 'limit': ['100'], 84 | 'offset': ['0'], 85 | }) 86 | -------------------------------------------------------------------------------- /tests/admin/test_policies.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestPolicies(TestAdmin): 7 | def setUp(self): 8 | super(TestPolicies, self).setUp() 9 | self.client.account_id = None 10 | self.client_list.account_id = None 11 | 12 | def test_delete_policy_v2(self): 13 | policy_key = "POSTGY2G0HVWJR4JO1XT" 14 | response = self.client.delete_policy_v2(policy_key) 15 | uri, _ = response["uri"].split("?") 16 | 17 | self.assertEqual(response["method"], "DELETE") 18 | self.assertEqual(uri, "/admin/v2/policies/{}".format(policy_key)) 19 | 20 | def test_get_policy_v2(self): 21 | policy_key = "POSTGY2G0HVWJR4JO1XT" 22 | response = self.client.get_policy_v2(policy_key) 23 | uri, _ = response["uri"].split("?") 24 | 25 | self.assertEqual(response["method"], "GET") 26 | self.assertEqual(uri, "/admin/v2/policies/{}".format(policy_key)) 27 | 28 | def test_update_policy_v2(self): 29 | policy_key = "POSTGY2G0HVWJR4JO1XT" 30 | policy_settings = { 31 | "sections": { 32 | "browsers": { 33 | "blocked_browsers_list": ["ie"], 34 | } 35 | } 36 | } 37 | response = self.client.update_policy_v2(policy_key, policy_settings) 38 | 39 | self.assertEqual(response["method"], "PUT") 40 | self.assertEqual(response["uri"], "/admin/v2/policies/{}".format(policy_key)) 41 | 42 | def test_update_policies_v2(self): 43 | edit_list = ["POSTGY2G0HVWJR4JO1XT", "POSTGY2G0HVWJR4JO1XU"] 44 | sections = { 45 | "browsers": { 46 | "blocked_browsers_list": ["ie"], 47 | } 48 | } 49 | response = self.client.update_policies_v2(sections, [], edit_list) 50 | self.assertEqual(response["method"], "PUT") 51 | self.assertEqual(response["uri"], "/admin/v2/policies/update") 52 | 53 | def test_create_policy_v2(self): 54 | policy_settings = { 55 | "name": "my new policy", 56 | "sections": { 57 | "browsers": { 58 | "blocked_browsers_list": ["ie"], 59 | } 60 | }, 61 | } 62 | response = self.client.create_policy_v2(policy_settings) 63 | 64 | self.assertEqual(response["method"], "POST") 65 | self.assertEqual(response["uri"], "/admin/v2/policies") 66 | 67 | def test_copy_policy_v2(self): 68 | policy_key = "POSTGY2G0HVWJR4JO1XT" 69 | new_policy_names_list = ["Copied Pol 1", "Copied Pol 2"] 70 | response = self.client.copy_policy_v2(policy_key, new_policy_names_list) 71 | 72 | self.assertEqual(response["method"], "POST") 73 | self.assertEqual(response["uri"], "/admin/v2/policies/copy") 74 | 75 | def test_get_policies_v2(self): 76 | response = self.client.get_policies_v2(limit=3, offset=0) 77 | uri, args = response["uri"].split("?") 78 | 79 | self.assertEqual(response["method"], "GET") 80 | self.assertEqual(uri, "/admin/v2/policies") 81 | self.assertDictEqual( 82 | util.params_to_dict(args), {"limit": ["3"], "offset": ["0"]} 83 | ) 84 | 85 | def test_get_policies_v2_iterator(self): 86 | iterator = self.client_list.get_policies_v2_iterator() 87 | response = next(iterator) 88 | uri, args = response["uri"].split("?") 89 | 90 | self.assertEqual(response["method"], "GET") 91 | self.assertEqual(uri, "/admin/v2/policies") 92 | self.assertDictEqual( 93 | util.params_to_dict(args), {"limit": ["100"], "offset": ["0"]} 94 | ) 95 | 96 | def test_get_policy_summary(self): 97 | response = self.client.get_policy_summary_v2() 98 | uri, _ = response["uri"].split("?") 99 | 100 | self.assertEqual(response["method"], "GET") 101 | self.assertEqual(uri, "/admin/v2/policies/summary") 102 | 103 | def test_calculate_policy(self): 104 | ikey = "DI82WWNVI5Z4V10LZJR6" 105 | ukey = "DUQU89MDEWOUR277H44G" 106 | 107 | response = self.client.calculate_policy(integration_key=ikey, user_id=ukey) 108 | uri, args = response["uri"].split("?") 109 | 110 | self.assertEqual(response["method"], "GET") 111 | self.assertEqual(uri, "/admin/v2/policies/calculate") 112 | self.assertDictEqual( 113 | util.params_to_dict(args), {"integration_key": [ikey], "user_id": [ukey]} 114 | ) -------------------------------------------------------------------------------- /tests/admin/test_registered_devices.py: -------------------------------------------------------------------------------- 1 | from .base import TestAdmin 2 | from .. import util 3 | 4 | 5 | class TestRegisteredDevices(TestAdmin): 6 | def test_get_registered_devices_generator(self): 7 | """ Test to get desktop tokens generator. 8 | """ 9 | generator = self.client_list.get_registered_devices_generator() 10 | response = next(generator) 11 | uri, args = response['uri'].split('?') 12 | 13 | self.assertEqual(response['method'], 'GET') 14 | self.assertEqual(uri, '/admin/v1/registered_devices') 15 | self.assertEqual(util.params_to_dict(args), 16 | {'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) 17 | 18 | def test_get_registered_devices(self): 19 | """ Test to get desktop tokens without params. 20 | """ 21 | response = self.client_list.get_registered_devices()[0] 22 | uri, args = response['uri'].split('?') 23 | 24 | self.assertEqual(response['method'], 'GET') 25 | self.assertEqual(uri, '/admin/v1/registered_devices') 26 | self.assertEqual(util.params_to_dict(args), 27 | {'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) 28 | 29 | def test_get_registered_devices_limit(self): 30 | """ Test to get desktop tokens with limit. 31 | """ 32 | response = self.client_list.get_registered_devices(limit='20')[0] 33 | uri, args = response['uri'].split('?') 34 | 35 | self.assertEqual(response['method'], 'GET') 36 | self.assertEqual(uri, '/admin/v1/registered_devices') 37 | self.assertEqual(util.params_to_dict(args), 38 | {'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['0'], }) 39 | 40 | def test_get_registered_devices_offset(self): 41 | """ Test to get desktop tokens with offset. 42 | """ 43 | response = self.client_list.get_registered_devices(offset='20')[0] 44 | uri, args = response['uri'].split('?') 45 | 46 | self.assertEqual(response['method'], 'GET') 47 | self.assertEqual(uri, '/admin/v1/registered_devices') 48 | self.assertEqual(util.params_to_dict(args), 49 | {'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) 50 | 51 | def test_get_registered_devices_limit_offset(self): 52 | """ Test to get desktop tokens with limit and offset. 53 | """ 54 | response = self.client_list.get_registered_devices(limit='20', offset='2')[0] 55 | uri, args = response['uri'].split('?') 56 | 57 | self.assertEqual(response['method'], 'GET') 58 | self.assertEqual(uri, '/admin/v1/registered_devices') 59 | self.assertEqual(util.params_to_dict(args), 60 | {'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['2'], }) 61 | 62 | def test_delete_registered_device(self): 63 | """ Test to delete registered device by registered device id. 64 | """ 65 | response = self.client.delete_registered_device('CRSFWW1YWVNUXMBJ1J29') 66 | uri, args = response['uri'].split('?') 67 | 68 | self.assertEqual(response['method'], 'DELETE') 69 | self.assertEqual(uri, '/admin/v1/registered_devices/CRSFWW1YWVNUXMBJ1J29') 70 | self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) 71 | -------------------------------------------------------------------------------- /tests/admin/test_settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .. import util 3 | import duo_client.admin 4 | from .base import TestAdmin 5 | 6 | 7 | class TestSettings(TestAdmin): 8 | 9 | def test_update_settings(self): 10 | """ Test updating settings 11 | """ 12 | self.maxDiff = None 13 | response = self.client_list.update_settings( 14 | lockout_threshold=10, 15 | lockout_expire_duration=60, 16 | inactive_user_expiration=30, 17 | pending_deletion_days=5, 18 | log_retention_days=180, 19 | sms_batch=5, 20 | sms_expiration=60, 21 | sms_refresh=True, 22 | sms_message='test_message', 23 | fraud_email='test@example.com', 24 | fraud_email_enabled=True, 25 | keypress_confirm='0', 26 | keypress_fraud='9', 27 | timezone='UTC', 28 | telephony_warning_min=50, 29 | caller_id='+15035551000', 30 | user_telephony_cost_max=10, 31 | minimum_password_length=12, 32 | password_requires_upper_alpha=True, 33 | password_requires_lower_alpha=True, 34 | password_requires_numeric=True, 35 | password_requires_special=True, 36 | helpdesk_bypass="allow", 37 | helpdesk_bypass_expiration=60, 38 | helpdesk_message="test_message", 39 | helpdesk_can_send_enroll_email=True, 40 | security_checkup_enabled=True, 41 | user_managers_can_put_users_in_bypass=False, 42 | email_activity_notification_enabled=True, 43 | push_activity_notification_enabled=True, 44 | unenrolled_user_lockout_threshold=100, 45 | enrollment_universal_prompt_enabled=True, 46 | ) 47 | response = response[0] 48 | self.assertEqual(response['method'], 'POST') 49 | self.assertEqual(response['uri'], '/admin/v1/settings') 50 | self.assertEqual( 51 | json.loads(response['body']), 52 | { 53 | 'account_id': self.client.account_id, 54 | 'lockout_threshold': '10', 55 | 'lockout_expire_duration': '60', 56 | 'inactive_user_expiration': '30', 57 | 'log_retention_days': '180', 58 | 'sms_batch': '5', 59 | 'sms_expiration': '60', 60 | 'sms_refresh': '1', 61 | 'sms_message': 'test_message', 62 | 'fraud_email': 'test@example.com', 63 | 'fraud_email_enabled': '1', 64 | 'keypress_confirm': '0', 65 | 'keypress_fraud': '9', 66 | 'timezone': 'UTC', 67 | 'telephony_warning_min': '50', 68 | 'caller_id': '+15035551000', 69 | 'user_telephony_cost_max': '10', 70 | 'pending_deletion_days': '5', 71 | 'minimum_password_length': '12', 72 | 'password_requires_upper_alpha': '1', 73 | 'password_requires_lower_alpha': '1', 74 | 'password_requires_numeric': '1', 75 | 'password_requires_special': '1', 76 | 'helpdesk_bypass': 'allow', 77 | 'helpdesk_bypass_expiration': '60', 78 | 'helpdesk_message': 'test_message', 79 | 'helpdesk_can_send_enroll_email': '1', 80 | 'security_checkup_enabled': '1', 81 | 'user_managers_can_put_users_in_bypass': '0', 82 | 'email_activity_notification_enabled': '1', 83 | 'push_activity_notification_enabled': '1', 84 | 'unenrolled_user_lockout_threshold': '100', 85 | 'enrollment_universal_prompt_enabled': '1', 86 | }) 87 | -------------------------------------------------------------------------------- /tests/admin/test_telephony.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | from .base import TestAdmin 3 | from datetime import datetime, timedelta 4 | from freezegun import freeze_time 5 | import pytz 6 | 7 | 8 | class TestTelephonyLogEndpoints(TestAdmin): 9 | def test_get_telephony_logs_v2(self): 10 | """Test to get activities log.""" 11 | response = self.items_response_client.get_telephony_log( 12 | maxtime=1663131599000, mintime=1662958799000, api_version=2 13 | ) 14 | uri, args = response["uri"].split("?") 15 | 16 | self.assertEqual(response["method"], "GET") 17 | self.assertEqual(uri, "/admin/v2/logs/telephony") 18 | self.assertEqual( 19 | util.params_to_dict(args)["account_id"], [self.items_response_client.account_id] 20 | ) 21 | 22 | @freeze_time("2022-10-01") 23 | def test_get_telephony_logs_v2_no_args(self): 24 | freezed_time = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) 25 | expected_mintime = str( 26 | int((freezed_time - timedelta(days=180)).timestamp() * 1000) 27 | ) 28 | expected_maxtime = str(int(freezed_time.timestamp() * 1000) - 120) 29 | response = self.items_response_client.get_telephony_log(api_version=2) 30 | uri, args = response["uri"].split("?") 31 | param_dict = util.params_to_dict(args) 32 | self.assertEqual(response["method"], "GET") 33 | self.assertEqual(uri, "/admin/v2/logs/telephony") 34 | self.assertEqual(param_dict["mintime"], [expected_mintime]) 35 | self.assertEqual(param_dict["maxtime"], [expected_maxtime]) 36 | self.assertAlmostEqual(param_dict["sort"], ["ts:desc"]) 37 | self.assertEqual(param_dict["limit"], ["100"]) 38 | 39 | @freeze_time("2022-10-01") 40 | def test_get_telephony_logs_v2_with_args(self): 41 | mintime = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) 42 | expected_mintime = str(int(mintime.timestamp() * 1000)) 43 | maxtime = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) 44 | expected_maxtime = str(int(maxtime.timestamp() * 1000) - 120) 45 | params = {"mintime": expected_mintime, "sort": "asc", "limit": 900} 46 | response = self.items_response_client.get_telephony_log(api_version=2, 47 | **params) 48 | uri, args = response["uri"].split("?") 49 | param_dict = util.params_to_dict(args) 50 | self.assertEqual(response["method"], "GET") 51 | self.assertEqual(uri, "/admin/v2/logs/telephony") 52 | self.assertEqual(param_dict["mintime"], [expected_mintime]) 53 | self.assertEqual(param_dict["maxtime"], [expected_maxtime]) 54 | self.assertEqual(param_dict["sort"], ["ts:asc"]) 55 | self.assertEqual(param_dict["limit"], ["900"]) 56 | 57 | @freeze_time("2022-10-01") 58 | def test_get_telephony_logs_v2_with_unsupported_args(self): 59 | params = { 60 | "unsupported": "argument", 61 | "non_existent": "argument" 62 | } 63 | response = self.items_response_client.get_telephony_log(api_version=2, 64 | **params) 65 | uri, args = response["uri"].split("?") 66 | param_dict = util.params_to_dict(args) 67 | self.assertEqual(response["method"], "GET") 68 | self.assertEqual(uri, "/admin/v2/logs/telephony") 69 | self.assertNotIn("unsupported", param_dict) 70 | self.assertNotIn("non_existent", param_dict) 71 | 72 | @freeze_time("2022-10-01") 73 | def test_get_telephony_logs_v1_no_args(self): 74 | response = self.client_list.get_telephony_log() 75 | uri, args = response[0]["uri"].split("?") 76 | self.assertEqual(response[0]["method"], "GET") 77 | self.assertEqual(uri, "/admin/v1/logs/telephony") 78 | 79 | @freeze_time("2022-10-01") 80 | def test_get_telephony_logs_v1_with_args(self): 81 | freezed_time = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) 82 | expected_mintime = str( 83 | int((freezed_time - timedelta(days=180)).timestamp()) 84 | ) 85 | response = self.client_list.get_telephony_log(mintime=expected_mintime) 86 | uri, args = response[0]["uri"].split("?") 87 | param_dict = util.params_to_dict(args) 88 | self.assertEqual(response[0]["method"], "GET") 89 | self.assertEqual(uri, "/admin/v1/logs/telephony") 90 | self.assertEqual(param_dict["mintime"], [expected_mintime]) 91 | 92 | @freeze_time("2022-10-01") 93 | def test_get_telephony_logs_v1_ignore_v2_args(self): 94 | freezed_time = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) 95 | expected_mintime = str( 96 | int((freezed_time - timedelta(days=180)).timestamp()) 97 | ) 98 | params = {"mintime": expected_mintime, "limit": 20, "sort": "ts:asc"} 99 | response = self.client_list.get_telephony_log(**params) 100 | uri, args = response[0]["uri"].split("?") 101 | param_dict = util.params_to_dict(args) 102 | self.assertEqual(response[0]["method"], "GET") 103 | self.assertEqual(uri, "/admin/v1/logs/telephony") 104 | self.assertEqual(param_dict["mintime"], [expected_mintime]) 105 | self.assertNotIn(param_dict["limit"], ["limit"]) 106 | self.assertNotIn(param_dict["sort"], ["sort"]) -------------------------------------------------------------------------------- /tests/admin/test_tokens.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestTokens(TestAdmin): 7 | def test_get_tokens_generator(self): 8 | """ Test to get tokens generator. 9 | """ 10 | generator = self.client_list.get_tokens_generator() 11 | response = next(generator) 12 | self.assertEqual(response['method'], 'GET') 13 | (uri, args) = response['uri'].split('?') 14 | self.assertEqual(uri, '/admin/v1/tokens') 15 | self.assertEqual( 16 | util.params_to_dict(args), 17 | { 18 | 'account_id': [self.client.account_id], 19 | 'limit': ['100'], 20 | 'offset': ['0'], 21 | }) 22 | 23 | def test_get_tokens(self): 24 | """ Test to get tokens without pagination params. 25 | """ 26 | response = self.client_list.get_tokens() 27 | response = response[0] 28 | self.assertEqual(response['method'], 'GET') 29 | (uri, args) = response['uri'].split('?') 30 | self.assertEqual(uri, '/admin/v1/tokens') 31 | self.assertEqual( 32 | util.params_to_dict(args), 33 | { 34 | 'account_id': [self.client.account_id], 35 | 'limit': ['100'], 36 | 'offset': ['0'], 37 | }) 38 | 39 | def test_get_tokens_with_limit(self): 40 | """ Test to get tokens with pagination params. 41 | """ 42 | response = self.client_list.get_tokens(limit=20) 43 | response = response[0] 44 | self.assertEqual(response['method'], 'GET') 45 | (uri, args) = response['uri'].split('?') 46 | self.assertEqual(uri, '/admin/v1/tokens') 47 | self.assertEqual( 48 | util.params_to_dict(args), 49 | { 50 | 'account_id': [self.client.account_id], 51 | 'limit': ['20'], 52 | 'offset': ['0'], 53 | }) 54 | 55 | def test_get_tokens_with_limit_offset(self): 56 | """ Test to get tokens with pagination params. 57 | """ 58 | response = self.client_list.get_tokens(limit=20, offset=2) 59 | response = response[0] 60 | self.assertEqual(response['method'], 'GET') 61 | (uri, args) = response['uri'].split('?') 62 | self.assertEqual(uri, '/admin/v1/tokens') 63 | self.assertEqual( 64 | util.params_to_dict(args), 65 | { 66 | 'account_id': [self.client.account_id], 67 | 'limit': ['20'], 68 | 'offset': ['2'], 69 | }) 70 | 71 | def test_get_tokens_with_offset(self): 72 | """ Test to get tokens with pagination params. 73 | """ 74 | response = self.client_list.get_tokens(offset=9001) 75 | response = response[0] 76 | self.assertEqual(response['method'], 'GET') 77 | (uri, args) = response['uri'].split('?') 78 | self.assertEqual(uri, '/admin/v1/tokens') 79 | self.assertEqual( 80 | util.params_to_dict(args), 81 | { 82 | 'account_id': [self.client.account_id], 83 | 'limit': ['100'], 84 | 'offset': ['0'], 85 | }) 86 | -------------------------------------------------------------------------------- /tests/admin/test_trust_monitor_events.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | MINTIME = 1603399970000 7 | MAXTIME = 1603399973797 8 | LIMIT = 10 9 | NEXT_OFFSET = "99999" 10 | EVENT_TYPE = "auth" 11 | 12 | 13 | class TestTrustMonitorEvents(TestAdmin): 14 | 15 | def test_get_trust_monitor_events_iterator(self): 16 | """ 17 | Test to ensure that the correct parameters are supplied when calling 18 | next on the generator. 19 | """ 20 | 21 | generator = self.client_dtm.get_trust_monitor_events_iterator( 22 | MINTIME, 23 | MAXTIME, 24 | event_type=EVENT_TYPE, 25 | ) 26 | events = [e for e in generator] 27 | self.assertEqual(events, [{"foo": "bar"},{"bar": "foo"}]) 28 | 29 | def test_get_trust_monitor_events_by_offset(self): 30 | """ 31 | Test to ensure that the correct parameters are supplied. 32 | """ 33 | 34 | res = self.client_list.get_trust_monitor_events_by_offset( 35 | MINTIME, 36 | MAXTIME, 37 | limit=LIMIT, 38 | offset=NEXT_OFFSET, 39 | event_type=EVENT_TYPE, 40 | )[0] 41 | uri, qry_str = res["uri"].split("?") 42 | args = util.params_to_dict(qry_str) 43 | 44 | self.assertEqual(res["method"], "GET") 45 | self.assertEqual(uri, "/admin/v1/trust_monitor/events") 46 | self.assertEqual(args["mintime"], ["1603399970000"]) 47 | self.assertEqual(args["maxtime"], ["1603399973797"]) 48 | self.assertEqual(args["offset"], ["99999"]) 49 | self.assertEqual(args["limit"], ["10"]) 50 | self.assertEqual(args["type"], ["auth"]) 51 | -------------------------------------------------------------------------------- /tests/admin/test_u2f.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestU2F(TestAdmin): 7 | def test_get_u2ftokens_with_params(self): 8 | """ Test to get u2ftokens with params. 9 | """ 10 | response = list(self.client_list.get_u2ftokens(limit=8))[0] 11 | uri, args = response['uri'].split('?') 12 | 13 | self.assertEqual(response['method'], 'GET') 14 | self.assertEqual(uri, '/admin/v1/u2ftokens') 15 | self.assertEqual( 16 | util.params_to_dict(args), 17 | { 18 | 'account_id':[self.client_list.account_id], 19 | 'limit': ['8'], 20 | 'offset': ['0'], 21 | } 22 | ) 23 | 24 | def test_get_u2ftokens_iterator(self): 25 | response = self.client_list.get_u2ftokens_iterator() 26 | response = next(response) 27 | uri, args = response['uri'].split('?') 28 | 29 | self.assertEqual(response['method'], 'GET') 30 | self.assertEqual(uri, '/admin/v1/u2ftokens') 31 | self.assertEqual( 32 | util.params_to_dict(args), 33 | { 34 | 'account_id': [self.client_list.account_id], 35 | 'limit': ['100'], 36 | 'offset': ['0'] 37 | } 38 | ) 39 | 40 | def test_get_u2ftokens_without_params(self): 41 | """ Test to get u2ftokens without params. 42 | """ 43 | response = list(self.client_list.get_u2ftokens())[0] 44 | uri, args = response['uri'].split('?') 45 | 46 | self.assertEqual(response['method'], 'GET') 47 | self.assertEqual(uri, '/admin/v1/u2ftokens') 48 | self.assertEqual( 49 | util.params_to_dict(args), 50 | { 51 | 'account_id': [self.client_list.account_id], 52 | 'limit': ['100'], 53 | 'offset': ['0'], 54 | } 55 | ) 56 | 57 | def test_get_u2ftokens_with_offset(self): 58 | response = list(self.client_list.get_u2ftokens(limit=2, offset=3))[0] 59 | uri, args = response['uri'].split('?') 60 | 61 | self.assertEqual(response['method'], 'GET') 62 | self.assertEqual(uri, '/admin/v1/u2ftokens') 63 | self.assertEqual( 64 | util.params_to_dict(args), 65 | { 66 | 'account_id':[self.client_list.account_id], 67 | 'limit': ['2'], 68 | 'offset': ['3'] 69 | } 70 | ) 71 | 72 | def test_get_u2ftoken_by_id(self): 73 | """ Test to get u2ftoken by registration id. 74 | """ 75 | response = self.client.get_u2ftoken_by_id('DU012345678901234567') 76 | uri, args = response['uri'].split('?') 77 | 78 | self.assertEqual(response['method'], 'GET') 79 | self.assertEqual(uri, '/admin/v1/u2ftokens/DU012345678901234567') 80 | self.assertEqual(util.params_to_dict(args), 81 | {'account_id':[self.client.account_id]}) 82 | 83 | def test_delete_u2ftoken(self): 84 | """ Test to delete u2ftoken by registration id. 85 | """ 86 | response = self.client.delete_u2ftoken('DU012345678901234567') 87 | uri, args = response['uri'].split('?') 88 | 89 | self.assertEqual(response['method'], 'DELETE') 90 | self.assertEqual(uri, '/admin/v1/u2ftokens/DU012345678901234567') 91 | self.assertEqual(util.params_to_dict(args), 92 | {'account_id':[self.client.account_id]}) 93 | -------------------------------------------------------------------------------- /tests/admin/test_user_bypass_codes.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestUserBypassCodes(TestAdmin): 7 | def test_get_user_bypass_codes(self): 8 | """ Test to get bypass codes by user id. 9 | """ 10 | response = self.client_list.get_user_bypass_codes( 11 | 'DU012345678901234567')[0] 12 | uri, args = response['uri'].split('?') 13 | 14 | self.assertEqual(response['method'], 'GET') 15 | self.assertEqual( 16 | uri, 17 | '/admin/v1/users/DU012345678901234567/bypass_codes') 18 | self.assertEqual( 19 | util.params_to_dict(args), 20 | { 21 | 'account_id': [self.client.account_id], 22 | 'limit': ['100'], 23 | 'offset': ['0'], 24 | }) 25 | 26 | def test_get_user_bypass_codes_iterator(self): 27 | """ Test to get bypass codes iterator by user id. 28 | """ 29 | iterator = self.client_list.get_user_bypass_codes_iterator( 30 | 'DU012345678901234567') 31 | response = next(iterator) 32 | uri, args = response['uri'].split('?') 33 | 34 | self.assertEqual(response['method'], 'GET') 35 | self.assertEqual( 36 | uri, 37 | '/admin/v1/users/DU012345678901234567/bypass_codes') 38 | self.assertEqual( 39 | util.params_to_dict(args), 40 | { 41 | 'account_id': [self.client.account_id], 42 | 'limit': ['100'], 43 | 'offset': ['0'], 44 | }) 45 | 46 | def test_get_user_bypass_codes_with_offset(self): 47 | """ Test to get bypass codes by user id with pagination params. 48 | """ 49 | response = self.client_list.get_user_bypass_codes( 50 | 'DU012345678901234567', offset=30)[0] 51 | uri, args = response['uri'].split('?') 52 | 53 | self.assertEqual(response['method'], 'GET') 54 | self.assertEqual( 55 | uri, 56 | '/admin/v1/users/DU012345678901234567/bypass_codes') 57 | self.assertEqual(util.params_to_dict(args), 58 | { 59 | 'account_id': [self.client.account_id], 60 | 'limit': ['100'], 61 | 'offset': ['0'], 62 | }) 63 | 64 | def test_get_user_bypass_codes_with_limit(self): 65 | """ Test to get bypass codes by user id with pagination params. 66 | """ 67 | response = self.client_list.get_user_bypass_codes( 68 | 'DU012345678901234567', limit=10)[0] 69 | uri, args = response['uri'].split('?') 70 | 71 | self.assertEqual(response['method'], 'GET') 72 | self.assertEqual( 73 | uri, 74 | '/admin/v1/users/DU012345678901234567/bypass_codes') 75 | self.assertEqual(util.params_to_dict(args), 76 | { 77 | 'account_id': [self.client.account_id], 78 | 'limit': ['10'], 79 | 'offset': ['0'], 80 | }) 81 | 82 | def test_get_user_bypass_codes_with_limit_and_offset(self): 83 | """ Test to get bypass codes by user id with pagination params. 84 | """ 85 | response = self.client_list.get_user_bypass_codes( 86 | 'DU012345678901234567', limit=10, offset=30)[0] 87 | uri, args = response['uri'].split('?') 88 | 89 | self.assertEqual(response['method'], 'GET') 90 | self.assertEqual( 91 | uri, 92 | '/admin/v1/users/DU012345678901234567/bypass_codes') 93 | self.assertEqual(util.params_to_dict(args), 94 | { 95 | 'account_id': [self.client.account_id], 96 | 'limit': ['10'], 97 | 'offset': ['30'], 98 | }) 99 | -------------------------------------------------------------------------------- /tests/admin/test_user_groups.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAdmin 6 | 7 | 8 | class TestUserGroups(TestAdmin): 9 | def test_get_user_groups_iterator(self): 10 | """ Test to get groups iterator by user id. 11 | """ 12 | generator = self.client_list.get_user_groups_iterator( 13 | 'DU012345678901234567') 14 | response = next(generator) 15 | uri, args = response['uri'].split('?') 16 | 17 | self.assertEqual(response['method'], 'GET') 18 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') 19 | self.assertEqual(util.params_to_dict(args), 20 | { 21 | 'account_id':[self.client.account_id], 22 | 'limit': ['100'], 23 | 'offset': ['0'], 24 | }) 25 | 26 | def test_get_user_groups(self): 27 | """ Test to get groups by user id. 28 | """ 29 | response = self.client_list.get_user_groups('DU012345678901234567')[0] 30 | uri, args = response['uri'].split('?') 31 | 32 | self.assertEqual(response['method'], 'GET') 33 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') 34 | self.assertEqual(util.params_to_dict(args), 35 | { 36 | 'account_id':[self.client.account_id], 37 | 'limit': ['100'], 38 | 'offset': ['0'], 39 | }) 40 | 41 | def test_get_user_groups_with_offset(self): 42 | """ Test to get groups by user id with pagination params. 43 | """ 44 | response = self.client_list.get_user_groups( 45 | 'DU012345678901234567', offset=60)[0] 46 | uri, args = response['uri'].split('?') 47 | 48 | self.assertEqual(response['method'], 'GET') 49 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') 50 | self.assertEqual(util.params_to_dict(args), 51 | { 52 | 'account_id':[self.client.account_id], 53 | 'limit': ['100'], 54 | 'offset': ['0'], 55 | }) 56 | 57 | def test_get_user_groups_with_limit(self): 58 | """ Test to get groups by user id with pagination params. 59 | """ 60 | response = self.client_list.get_user_groups( 61 | 'DU012345678901234567', limit=30)[0] 62 | uri, args = response['uri'].split('?') 63 | 64 | self.assertEqual(response['method'], 'GET') 65 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') 66 | self.assertEqual(util.params_to_dict(args), 67 | { 68 | 'account_id':[self.client.account_id], 69 | 'limit': ['30'], 70 | 'offset': ['0'], 71 | }) 72 | 73 | def test_get_user_groups_with_limit_and_offset(self): 74 | """ Test to get groups by user id with pagination params. 75 | """ 76 | response = self.client_list.get_user_groups( 77 | 'DU012345678901234567', limit=30, offset=60)[0] 78 | uri, args = response['uri'].split('?') 79 | 80 | self.assertEqual(response['method'], 'GET') 81 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') 82 | self.assertEqual(util.params_to_dict(args), 83 | { 84 | 'account_id':[self.client.account_id], 85 | 'limit': ['30'], 86 | 'offset': ['60'], 87 | }) 88 | 89 | 90 | if __name__ == '__main__': 91 | unittest.main() 92 | -------------------------------------------------------------------------------- /tests/admin/test_user_phones.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAdmin 6 | 7 | 8 | class TestUserPhones(TestAdmin): 9 | def test_get_user_phones_iterator(self): 10 | """Test to get phones iterator by user id 11 | """ 12 | iterator = self.client_list.get_user_phones_iterator( 13 | 'DU012345678901234567') 14 | response = next(iterator) 15 | uri, args = response['uri'].split('?') 16 | 17 | self.assertEqual(response['method'], 'GET') 18 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') 19 | self.assertEqual(util.params_to_dict(args), 20 | { 21 | 'account_id':[self.client.account_id], 22 | 'limit': ['100'], 23 | 'offset': ['0'], 24 | }) 25 | 26 | def test_get_user_phones(self): 27 | """Test to get phones by user id 28 | """ 29 | response = self.client_list.get_user_phones('DU012345678901234567')[0] 30 | uri, args = response['uri'].split('?') 31 | 32 | self.assertEqual(response['method'], 'GET') 33 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') 34 | self.assertEqual(util.params_to_dict(args), 35 | { 36 | 'account_id':[self.client.account_id], 37 | 'limit': ['100'], 38 | 'offset': ['0'], 39 | }) 40 | 41 | def test_get_user_phones_with_offset(self): 42 | """Test to get phones by user id with pagination params 43 | """ 44 | response = self.client_list.get_user_phones( 45 | 'DU012345678901234567', offset=30)[0] 46 | uri, args = response['uri'].split('?') 47 | 48 | self.assertEqual(response['method'], 'GET') 49 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') 50 | self.assertEqual(util.params_to_dict(args), 51 | { 52 | 'account_id':[self.client.account_id], 53 | 'limit': ['100'], 54 | 'offset': ['0'], 55 | }) 56 | 57 | def test_get_user_phones_with_limit(self): 58 | """Test to get phones by user id with pagination params 59 | """ 60 | response = self.client_list.get_user_phones( 61 | 'DU012345678901234567', limit=10)[0] 62 | uri, args = response['uri'].split('?') 63 | 64 | self.assertEqual(response['method'], 'GET') 65 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') 66 | self.assertEqual(util.params_to_dict(args), 67 | { 68 | 'account_id':[self.client.account_id], 69 | 'limit': ['10'], 70 | 'offset': ['0'], 71 | }) 72 | 73 | def test_get_user_phones_with_limit_and_offset(self): 74 | """Test to get phones by user id with pagination params 75 | """ 76 | response = self.client_list.get_user_phones( 77 | 'DU012345678901234567', limit=10, offset=30)[0] 78 | uri, args = response['uri'].split('?') 79 | 80 | self.assertEqual(response['method'], 'GET') 81 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') 82 | self.assertEqual(util.params_to_dict(args), 83 | { 84 | 'account_id':[self.client.account_id], 85 | 'limit': ['10'], 86 | 'offset': ['30'], 87 | }) 88 | 89 | 90 | if __name__ == '__main': 91 | unittest.main() 92 | -------------------------------------------------------------------------------- /tests/admin/test_user_tokens.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAdmin 6 | 7 | 8 | class TestUserTokens(TestAdmin): 9 | def test_get_user_tokens_iterator(self): 10 | """ Test to get tokens iterator by user id. 11 | """ 12 | generator = self.client_list.get_user_tokens_iterator( 13 | 'DU012345678901234567') 14 | response = next(generator) 15 | uri, args = response['uri'].split('?') 16 | 17 | self.assertEqual(response['method'], 'GET') 18 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') 19 | self.assertEqual(util.params_to_dict(args), 20 | { 21 | 'account_id':[self.client.account_id], 22 | 'limit': ['100'], 23 | 'offset': ['0'], 24 | }) 25 | 26 | def test_get_user_tokens(self): 27 | """ Test to get tokens by user id. 28 | """ 29 | response = self.client_list.get_user_tokens('DU012345678901234567')[0] 30 | uri, args = response['uri'].split('?') 31 | 32 | self.assertEqual(response['method'], 'GET') 33 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') 34 | self.assertEqual(util.params_to_dict(args), 35 | { 36 | 'account_id':[self.client.account_id], 37 | 'limit': ['100'], 38 | 'offset': ['0'], 39 | }) 40 | 41 | def test_get_user_tokens_offset(self): 42 | """ Test to get tokens by user id with pagination params. 43 | """ 44 | response = self.client_list.get_user_tokens( 45 | 'DU012345678901234567', offset=100)[0] 46 | uri, args = response['uri'].split('?') 47 | 48 | self.assertEqual(response['method'], 'GET') 49 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') 50 | self.assertEqual(util.params_to_dict(args), 51 | { 52 | 'account_id':[self.client.account_id], 53 | 'limit': ['100'], 54 | 'offset': ['0'], 55 | }) 56 | 57 | def test_get_user_tokens_limit(self): 58 | """ Test to get tokens by user id with pagination params. 59 | """ 60 | response = self.client_list.get_user_tokens( 61 | 'DU012345678901234567', limit=500)[0] 62 | uri, args = response['uri'].split('?') 63 | 64 | self.assertEqual(response['method'], 'GET') 65 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') 66 | self.assertEqual(util.params_to_dict(args), 67 | { 68 | 'account_id':[self.client.account_id], 69 | 'limit': ['500'], 70 | 'offset': ['0'], 71 | }) 72 | 73 | def test_get_user_tokens_limit_and_offset(self): 74 | """ Test to get tokens by user id with pagination params. 75 | """ 76 | response = self.client_list.get_user_tokens( 77 | 'DU012345678901234567', limit=10, offset=100)[0] 78 | uri, args = response['uri'].split('?') 79 | 80 | self.assertEqual(response['method'], 'GET') 81 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') 82 | self.assertEqual(util.params_to_dict(args), 83 | { 84 | 'account_id':[self.client.account_id], 85 | 'limit': ['10'], 86 | 'offset': ['100'], 87 | }) 88 | 89 | 90 | if __name__ == '__main__': 91 | unittest.main() 92 | -------------------------------------------------------------------------------- /tests/admin/test_user_u2f.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAdmin 6 | 7 | 8 | class TestUserU2F(TestAdmin): 9 | def test_get_user_u2ftokens_iterator(self): 10 | """ Test to get u2ftokens iterator by user id. 11 | """ 12 | iterator = self.client_list.get_user_u2ftokens_iterator( 13 | 'DU012345678901234567') 14 | response = next(iterator) 15 | uri, args = response['uri'].split('?') 16 | 17 | self.assertEqual(response['method'], 'GET') 18 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') 19 | self.assertEqual(util.params_to_dict(args), 20 | { 21 | 'account_id':[self.client.account_id], 22 | 'limit': ['100'], 23 | 'offset': ['0'], 24 | }) 25 | 26 | def test_get_user_u2ftokens(self): 27 | """ Test to get u2ftokens by user id. 28 | """ 29 | response = self.client_list.get_user_u2ftokens( 30 | 'DU012345678901234567')[0] 31 | uri, args = response['uri'].split('?') 32 | 33 | self.assertEqual(response['method'], 'GET') 34 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') 35 | self.assertEqual(util.params_to_dict(args), 36 | { 37 | 'account_id':[self.client.account_id], 38 | 'limit': ['100'], 39 | 'offset': ['0'], 40 | }) 41 | 42 | def test_get_user_u2ftokens_with_offset(self): 43 | """ Test to get u2ftokens by user id with pagination params. 44 | """ 45 | response = self.client_list.get_user_u2ftokens('DU012345678901234567', 46 | offset=30)[0] 47 | uri, args = response['uri'].split('?') 48 | 49 | self.assertEqual(response['method'], 'GET') 50 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') 51 | self.assertEqual(util.params_to_dict(args), 52 | { 53 | 'account_id':[self.client.account_id], 54 | 'limit': ['100'], 55 | 'offset': ['0'], 56 | }) 57 | 58 | def test_get_user_u2ftokens_with_limit(self): 59 | """ Test to get u2ftokens by user id with pagination params. 60 | """ 61 | response = self.client_list.get_user_u2ftokens('DU012345678901234567', 62 | limit=10)[0] 63 | uri, args = response['uri'].split('?') 64 | 65 | self.assertEqual(response['method'], 'GET') 66 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') 67 | self.assertEqual(util.params_to_dict(args), 68 | { 69 | 'account_id':[self.client.account_id], 70 | 'limit': ['10'], 71 | 'offset': ['0'], 72 | }) 73 | 74 | def test_get_user_u2ftokens_with_limit_and_offset(self): 75 | """ Test to get u2ftokens by user id with pagination params. 76 | """ 77 | response = self.client_list.get_user_u2ftokens('DU012345678901234567', 78 | limit=10, offset=30)[0] 79 | uri, args = response['uri'].split('?') 80 | 81 | self.assertEqual(response['method'], 'GET') 82 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') 83 | self.assertEqual(util.params_to_dict(args), 84 | { 85 | 'account_id':[self.client.account_id], 86 | 'limit': ['10'], 87 | 'offset': ['30'], 88 | }) 89 | 90 | 91 | if __name__ == '__main__': 92 | unittest.main() 93 | -------------------------------------------------------------------------------- /tests/admin/test_user_webauthn.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import util 4 | import duo_client.admin 5 | from .base import TestAdmin 6 | 7 | 8 | class TestUserTestWebauthn(TestAdmin): 9 | def test_get_user_webauthncredentials_iterator(self): 10 | """ Test to get webauthn credentials iterator by user id. 11 | """ 12 | iterator = self.client_list.get_user_webauthncredentials_iterator( 13 | 'DU012345678901234567') 14 | response = next(iterator) 15 | uri, args = response['uri'].split('?') 16 | 17 | self.assertEqual(response['method'], 'GET') 18 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') 19 | self.assertEqual(util.params_to_dict(args), 20 | { 21 | 'account_id':[self.client.account_id], 22 | 'limit': ['100'], 23 | 'offset': ['0'], 24 | }) 25 | 26 | def test_get_user_webauthncredentials(self): 27 | """ Test to get webauthn credentials by user id. 28 | """ 29 | response = self.client_list.get_user_webauthncredentials( 30 | 'DU012345678901234567')[0] 31 | uri, args = response['uri'].split('?') 32 | 33 | self.assertEqual(response['method'], 'GET') 34 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') 35 | self.assertEqual(util.params_to_dict(args), 36 | { 37 | 'account_id':[self.client.account_id], 38 | 'limit': ['100'], 39 | 'offset': ['0'], 40 | }) 41 | 42 | def test_get_user_webauthncredentials_with_offset(self): 43 | """ Test to get webauthn credentials by user id with pagination params. 44 | """ 45 | response = self.client_list.get_user_webauthncredentials('DU012345678901234567', 46 | offset=30)[0] 47 | uri, args = response['uri'].split('?') 48 | 49 | self.assertEqual(response['method'], 'GET') 50 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') 51 | self.assertEqual(util.params_to_dict(args), 52 | { 53 | 'account_id':[self.client.account_id], 54 | 'limit': ['100'], 55 | 'offset': ['0'], 56 | }) 57 | 58 | def test_get_user_webauthncredentials_with_limit(self): 59 | """ Test to get webauthn credentials by user id with pagination params. 60 | """ 61 | response = self.client_list.get_user_webauthncredentials('DU012345678901234567', 62 | limit=10)[0] 63 | uri, args = response['uri'].split('?') 64 | 65 | self.assertEqual(response['method'], 'GET') 66 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') 67 | self.assertEqual(util.params_to_dict(args), 68 | { 69 | 'account_id':[self.client.account_id], 70 | 'limit': ['10'], 71 | 'offset': ['0'], 72 | }) 73 | 74 | def test_get_user_webauthncredentials_with_limit_and_offset(self): 75 | """ Test to get webauthn credentials by user id with pagination params. 76 | """ 77 | response = self.client_list.get_user_webauthncredentials('DU012345678901234567', 78 | limit=10, offset=30)[0] 79 | uri, args = response['uri'].split('?') 80 | 81 | self.assertEqual(response['method'], 'GET') 82 | self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') 83 | self.assertEqual(util.params_to_dict(args), 84 | { 85 | 'account_id':[self.client.account_id], 86 | 'limit': ['10'], 87 | 'offset': ['30'], 88 | }) 89 | 90 | 91 | if __name__ == '__main__': 92 | unittest.main() 93 | -------------------------------------------------------------------------------- /tests/admin/test_users.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .. import util 3 | import duo_client.admin 4 | from .base import TestAdmin 5 | 6 | 7 | class TestUsers(TestAdmin): 8 | def test_get_users_generator(self): 9 | """ Test to get users iterator. 10 | """ 11 | iterator = self.client_list.get_users_iterator() 12 | response = next(iterator) 13 | self.assertEqual(response['method'], 'GET') 14 | (uri, args) = response['uri'].split('?') 15 | self.assertEqual(uri, '/admin/v1/users') 16 | self.assertEqual( 17 | util.params_to_dict(args), 18 | { 19 | 'account_id': [self.client.account_id], 20 | 'limit': ['100'], 21 | 'offset': ['0'], 22 | }) 23 | 24 | def test_get_users(self): 25 | """ Test to get users. 26 | """ 27 | response = self.client_list.get_users()[0] 28 | self.assertEqual(response['method'], 'GET') 29 | (uri, args) = response['uri'].split('?') 30 | self.assertEqual(uri, '/admin/v1/users') 31 | self.assertEqual( 32 | util.params_to_dict(args), 33 | { 34 | 'account_id': [self.client.account_id], 35 | 'limit': ['100'], 36 | 'offset': ['0'], 37 | }) 38 | 39 | def test_get_users_offset(self): 40 | """ Test to get users with pagination params. 41 | """ 42 | response = self.client_list.get_users(offset=30)[0] 43 | self.assertEqual(response['method'], 'GET') 44 | (uri, args) = response['uri'].split('?') 45 | self.assertEqual(uri, '/admin/v1/users') 46 | self.assertEqual( 47 | util.params_to_dict(args), 48 | { 49 | 'account_id': [self.client.account_id], 50 | 'limit': ['100'], 51 | 'offset': ['0'], 52 | }) 53 | 54 | def test_get_users_limit(self): 55 | """ Test to get users with pagination params. 56 | """ 57 | response = self.client_list.get_users(limit=30)[0] 58 | self.assertEqual(response['method'], 'GET') 59 | (uri, args) = response['uri'].split('?') 60 | self.assertEqual(uri, '/admin/v1/users') 61 | self.assertEqual( 62 | util.params_to_dict(args), 63 | { 64 | 'account_id': [self.client.account_id], 65 | 'limit': ['30'], 66 | 'offset': ['0'], 67 | }) 68 | 69 | def test_get_users_limit_and_offset(self): 70 | """ Test to get users with pagination params. 71 | """ 72 | response = self.client_list.get_users(limit=20, offset=30)[0] 73 | self.assertEqual(response['method'], 'GET') 74 | (uri, args) = response['uri'].split('?') 75 | self.assertEqual(uri, '/admin/v1/users') 76 | self.assertEqual( 77 | util.params_to_dict(args), 78 | { 79 | 'account_id': [self.client.account_id], 80 | 'limit': ['20'], 81 | 'offset': ['30'], 82 | }) 83 | 84 | # GET with params 85 | def test_get_users_by_name(self): 86 | response = self.client.get_users_by_name('foo') 87 | (uri, args) = response['uri'].split('?') 88 | self.assertEqual(response['method'], 'GET') 89 | self.assertEqual(uri, '/admin/v1/users') 90 | self.assertEqual( 91 | util.params_to_dict(args), 92 | {'username':['foo'], 93 | 'account_id':[self.client.account_id]}) 94 | self.assertEqual(response['body'], None) 95 | response = self.client.get_users_by_name('foo') 96 | (uri, args) = response['uri'].split('?') 97 | self.assertEqual(response['method'], 'GET') 98 | self.assertEqual(uri, '/admin/v1/users') 99 | self.assertEqual( 100 | util.params_to_dict(args), 101 | {'username':['foo'], 102 | 'account_id':[self.client.account_id]}) 103 | self.assertEqual(response['body'], None) 104 | 105 | # POST with params 106 | def test_add_user(self): 107 | # all params given 108 | response = self.client.add_user( 109 | 'foo', realname='bar', status='active', notes='notes', 110 | email='foobar@baz.com', firstname='fName', lastname='lName', 111 | alias1='alias1', alias2='alias2', alias3='alias3', alias4='alias4') 112 | self.assertEqual(response['method'], 'POST') 113 | self.assertEqual(response['uri'], '/admin/v1/users') 114 | self.assertEqual( 115 | json.loads(response['body']), 116 | { 117 | 'realname': 'bar', 118 | 'notes': 'notes', 119 | 'username': 'foo', 120 | 'status': 'active', 121 | 'email': 'foobar@baz.com', 122 | 'firstname': 'fName', 123 | 'lastname': 'lName', 124 | 'account_id': self.client.account_id, 125 | 'alias1': 'alias1', 126 | 'alias2': 'alias2', 127 | 'alias3': 'alias3', 128 | 'alias4': 'alias4', 129 | }) 130 | # defaults 131 | response = self.client.add_user('bar') 132 | self.assertEqual(response['method'], 'POST') 133 | self.assertEqual(response['uri'], '/admin/v1/users') 134 | self.assertEqual( 135 | json.loads(response['body']), 136 | {'username':'bar', 'account_id':self.client.account_id}) 137 | 138 | def test_update_user(self): 139 | response = self.client.update_user( 140 | 'DU012345678901234567', username='foo', realname='bar', 141 | status='active', notes='notes', email='foobar@baz.com', 142 | firstname='fName', lastname='lName', alias1='alias1', 143 | alias2='alias2', alias3='alias3', alias4='alias4') 144 | self.assertEqual(response['method'], 'POST') 145 | self.assertEqual( 146 | response['uri'], '/admin/v1/users/DU012345678901234567') 147 | self.assertEqual( 148 | json.loads(response['body']), 149 | { 150 | 'account_id':self.client.account_id, 151 | 'realname': 'bar', 152 | 'notes': 'notes', 153 | 'username': 'foo', 154 | 'status': 'active', 155 | 'email': 'foobar@baz.com', 156 | 'firstname': 'fName', 157 | 'lastname': 'lName', 158 | 'account_id': self.client.account_id, 159 | 'alias1': 'alias1', 160 | 'alias2': 'alias2', 161 | 'alias3': 'alias3', 162 | 'alias4': 'alias4', 163 | }) 164 | 165 | def test_sync_user(self): 166 | """ Test to synchronize a single user in a directory for a username. 167 | """ 168 | response = self.client.sync_user('foo', 'test_dir_key') 169 | self.assertEqual(response['method'], 'POST') 170 | self.assertEqual(response['uri'], 171 | '/admin/v1/users/directorysync/test_dir_key/syncuser') 172 | self.assertEqual( 173 | json.loads(response['body']), 174 | {'username': 'foo', 'account_id': self.client.account_id}) 175 | -------------------------------------------------------------------------------- /tests/admin/test_verification_push.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .. import util 3 | from .base import TestAdmin 4 | 5 | 6 | class TestVerificationPush(TestAdmin): 7 | def test_send_verification_push(self): 8 | """ 9 | Test sending a verification push to a user. 10 | """ 11 | response = self.client.send_verification_push('test_user_id', 'test_phone_id') 12 | self.assertEqual(response['method'], 'POST') 13 | self.assertEqual(response['uri'], 14 | '/admin/v1/users/test_user_id/send_verification_push') 15 | self.assertEqual( 16 | json.loads(response['body']), 17 | {'phone_id': 'test_phone_id', 'account_id': self.client.account_id}) 18 | 19 | def test_get_verification_push_response(self): 20 | """ 21 | Test getting the verification push response. 22 | """ 23 | response = self.client.get_verification_push_response('test_user_id', 'test_push_id') 24 | (uri, args) = response['uri'].split('?') 25 | self.assertEqual(response['method'], 'GET') 26 | self.assertEqual(uri, '/admin/v1/users/test_user_id/verification_push_response') 27 | 28 | self.assertEqual( 29 | util.params_to_dict(args), 30 | {'push_id': ['test_push_id'], 'account_id': [self.client.account_id]}) 31 | -------------------------------------------------------------------------------- /tests/admin/test_webauthn.py: -------------------------------------------------------------------------------- 1 | from .. import util 2 | import duo_client.admin 3 | from .base import TestAdmin 4 | 5 | 6 | class TestWebauthn(TestAdmin): 7 | def test_get_webauthncredentials_with_params(self): 8 | """ Test to get webauthn credentials with params. 9 | """ 10 | response = list(self.client_list.get_webauthncredentials(limit=8))[0] 11 | uri, args = response['uri'].split('?') 12 | 13 | self.assertEqual(response['method'], 'GET') 14 | self.assertEqual(uri, '/admin/v1/webauthncredentials') 15 | self.assertEqual( 16 | util.params_to_dict(args), 17 | { 18 | 'account_id':[self.client_list.account_id], 19 | 'limit': ['8'], 20 | 'offset': ['0'], 21 | } 22 | ) 23 | 24 | def test_get_webauthncredentials_iterator(self): 25 | response = self.client_list.get_webauthncredentials_iterator() 26 | response = next(response) 27 | uri, args = response['uri'].split('?') 28 | 29 | self.assertEqual(response['method'], 'GET') 30 | self.assertEqual(uri, '/admin/v1/webauthncredentials') 31 | self.assertEqual( 32 | util.params_to_dict(args), 33 | { 34 | 'account_id': [self.client_list.account_id], 35 | 'limit': ['100'], 36 | 'offset': ['0'] 37 | } 38 | ) 39 | 40 | def test_get_webauthncredentials_without_params(self): 41 | """ Test to get webauthn credentials without params. 42 | """ 43 | response = list(self.client_list.get_webauthncredentials())[0] 44 | uri, args = response['uri'].split('?') 45 | 46 | self.assertEqual(response['method'], 'GET') 47 | self.assertEqual(uri, '/admin/v1/webauthncredentials') 48 | self.assertEqual( 49 | util.params_to_dict(args), 50 | { 51 | 'account_id': [self.client_list.account_id], 52 | 'limit': ['100'], 53 | 'offset': ['0'], 54 | } 55 | ) 56 | 57 | def test_get_webauthncredentials_with_offset(self): 58 | response = list(self.client_list.get_webauthncredentials(limit=2, offset=3))[0] 59 | uri, args = response['uri'].split('?') 60 | 61 | self.assertEqual(response['method'], 'GET') 62 | self.assertEqual(uri, '/admin/v1/webauthncredentials') 63 | self.assertEqual( 64 | util.params_to_dict(args), 65 | { 66 | 'account_id':[self.client_list.account_id], 67 | 'limit': ['2'], 68 | 'offset': ['3'] 69 | } 70 | ) 71 | 72 | def test_get_webauthncredential_by_id(self): 73 | """ Test to get webauthn credential by registration id. 74 | """ 75 | response = self.client.get_webauthncredential_by_id('DU012345678901234567') 76 | uri, args = response['uri'].split('?') 77 | 78 | self.assertEqual(response['method'], 'GET') 79 | self.assertEqual(uri, '/admin/v1/webauthncredentials/DU012345678901234567') 80 | self.assertEqual(util.params_to_dict(args), 81 | {'account_id':[self.client.account_id]}) 82 | 83 | def test_delete_webauthncredential(self): 84 | """ Test to delete webauthn credential by registration id. 85 | """ 86 | response = self.client.delete_webauthncredential('DU012345678901234567') 87 | uri, args = response['uri'].split('?') 88 | 89 | self.assertEqual(response['method'], 'DELETE') 90 | self.assertEqual(uri, '/admin/v1/webauthncredentials/DU012345678901234567') 91 | self.assertEqual(util.params_to_dict(args), 92 | {'account_id':[self.client.account_id]}) -------------------------------------------------------------------------------- /tests/resources/barn-owl-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosecurity/duo_client_python/b3576230e2456226860519d8c07d9cd92ce3a76f/tests/resources/barn-owl-small.png -------------------------------------------------------------------------------- /tests/test_https_wrapper.py: -------------------------------------------------------------------------------- 1 | from duo_client.https_wrapper import CertValidatingHTTPSConnection 2 | import unittest 3 | from unittest import mock 4 | import ssl 5 | 6 | class TestSSLContextCreation(unittest.TestCase): 7 | """ Test that the SSL context used to wrap sockets is configured correctly """ 8 | def test_no_ca_certs(self): 9 | conn = CertValidatingHTTPSConnection('api-fakehost.duosecurity.com') 10 | self.assertEqual(conn.default_ssl_context.verify_mode, ssl.CERT_NONE) # noqa: DUO122, testing insecure context 11 | 12 | @mock.patch('ssl.SSLContext.load_verify_locations') 13 | def test_with_ca_certs(self, mock_load): 14 | mock_load.return_value = None 15 | conn = CertValidatingHTTPSConnection('api-fakehost.duosecurity.com', ca_certs='cafilepath') 16 | self.assertEqual(conn.default_ssl_context.verify_mode, ssl.CERT_REQUIRED) 17 | mock_load.assert_called_with(cafile='cafilepath') 18 | 19 | @mock.patch('ssl.SSLContext.load_cert_chain') 20 | def test_with_certfile(self, mock_load): 21 | mock_load.return_value = None 22 | CertValidatingHTTPSConnection('api-fakehost.duosecurity.com', cert_file='certfilepath') 23 | mock_load.assert_called_with('certfilepath', None) 24 | 25 | def test_ssl2_ssl3_off(self): 26 | conn = CertValidatingHTTPSConnection('api-fakehost.duosecurity.com') 27 | self.assertEqual(conn.default_ssl_context.options & ssl.OP_NO_SSLv2, ssl.OP_NO_SSLv2) 28 | self.assertEqual(conn.default_ssl_context.options & ssl.OP_NO_SSLv3, ssl.OP_NO_SSLv3) 29 | 30 | @mock.patch('socket.socket.connect') 31 | def test_server_hostname(self, mock_connect): 32 | hostname = 'api-fakehost.duosecurity.com' 33 | conn = CertValidatingHTTPSConnection(hostname) 34 | conn.connect() 35 | self.assertEqual(conn.sock.server_hostname, hostname) 36 | 37 | @mock.patch('socket.socket.connect') 38 | def test_server_hostname_with_port(self, mock_connect): 39 | hostname = 'api-fakehost.duosecurity.com' 40 | conn = CertValidatingHTTPSConnection(f'{hostname}:443') 41 | conn.connect() 42 | self.assertEqual(conn.sock.server_hostname, hostname) 43 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import collections 3 | import urllib.parse 4 | 5 | from json import JSONEncoder 6 | import duo_client 7 | 8 | class MockObjectJsonEncoder(json.JSONEncoder): 9 | def default(self, obj): 10 | return getattr(obj.__class__, "to_json")(obj) 11 | 12 | # put params in a dict to avoid inconsistent ordering 13 | def params_to_dict(param_str): 14 | param_dict = collections.defaultdict(list) 15 | for (key, val) in (param.split('=') for param in param_str.split('&')): 16 | param_dict[key].append(urllib.parse.unquote(val)) 17 | return param_dict 18 | 19 | 20 | class MockHTTPConnection(object): 21 | """ 22 | Mock HTTP(S) connection that returns a dummy JSON response. 23 | """ 24 | status = 200 # success! 25 | 26 | def __init__( 27 | self, 28 | data_response_should_be_list=False, 29 | data_response_from_get_authlog=False, 30 | data_response_from_get_dtm_events=False, 31 | data_response_from_get_items=False, 32 | ): 33 | # if a response object should be a list rather than 34 | # a dict, then set this flag to true 35 | self.data_response_should_be_list = data_response_should_be_list 36 | self.data_response_from_get_authlog = data_response_from_get_authlog 37 | self.data_response_from_get_dtm_events = data_response_from_get_dtm_events 38 | self.data_response_from_get_items = data_response_from_get_items 39 | 40 | def dummy(self): 41 | return self 42 | 43 | _connect = _disconnect = close = getresponse = dummy 44 | 45 | def read(self): 46 | response = self.__dict__ 47 | 48 | if self.data_response_should_be_list: 49 | response = [self.__dict__] 50 | 51 | if self.data_response_from_get_authlog: 52 | response['authlogs'] = [] 53 | 54 | if self.data_response_from_get_items: 55 | response['items'] = [] 56 | 57 | if self.data_response_from_get_dtm_events: 58 | response['events'] = [{"foo": "bar"}, {"bar": "foo"}] 59 | 60 | return json.dumps({"stat":"OK", "response":response}, 61 | cls=MockObjectJsonEncoder) 62 | 63 | def request(self, method, uri, body, headers): 64 | self.method = method 65 | self.uri = uri 66 | self.body = body 67 | 68 | self.headers = {} 69 | for k, v in headers.items(): 70 | if isinstance(k, bytes): 71 | k = k.decode('ascii') 72 | if isinstance(v, bytes): 73 | v = v.decode('ascii') 74 | self.headers[k] = v 75 | 76 | 77 | class MockJsonObject(object): 78 | def to_json(self): 79 | return {'id': id(self)} 80 | 81 | class CountingClient(duo_client.client.Client): 82 | def __init__(self, *args, **kwargs): 83 | super(CountingClient, self).__init__(*args, **kwargs) 84 | self.counter = 0 85 | 86 | def _make_request(self, *args, **kwargs): 87 | self.counter += 1 88 | return super(CountingClient, self)._make_request(*args, **kwargs) 89 | 90 | 91 | class MockPagingHTTPConnection(MockHTTPConnection): 92 | def __init__(self, objects=None): 93 | if objects is not None: 94 | self.objects = objects 95 | 96 | def dummy(self): 97 | return self 98 | 99 | _connect = _disconnect = close = getresponse = dummy 100 | 101 | def read(self): 102 | metadata = {} 103 | metadata['total_objects'] = len(self.objects) 104 | if self.offset + self.limit < len(self.objects): 105 | metadata['next_offset'] = self.offset + self.limit 106 | if self.offset > 0: 107 | metadata['prev_offset'] = max(self.offset-self.limit, 0) 108 | 109 | return json.dumps( 110 | {"stat":"OK", 111 | "response": self.objects[self.offset: self.offset+self.limit], 112 | "metadata": metadata}, 113 | cls=MockObjectJsonEncoder) 114 | 115 | def request(self, method, uri, body, headers): 116 | self.method = method 117 | self.uri = uri 118 | self.body = body 119 | self.headers = headers 120 | parsed = urllib.parse.urlparse(uri) 121 | params = urllib.parse.parse_qs(parsed.query) 122 | 123 | self.limit = int(params['limit'][0]) 124 | 125 | # offset is always present with list-based paging but cannot be 126 | # present on the initial request with cursor-based paging 127 | self.offset = int(params.get('offset', [0])[0]) 128 | 129 | class MockAlternatePagingHTTPConnection(MockPagingHTTPConnection): 130 | def read(self): 131 | metadata = {} 132 | metadata['total_objects'] = len(self.objects) 133 | if self.offset + self.limit < len(self.objects): 134 | metadata['next_offset'] = self.offset + self.limit 135 | if self.offset > 0: 136 | metadata['prev_offset'] = max(self.offset-self.limit, 0) 137 | 138 | return json.dumps( 139 | {"stat":"OK", 140 | "response": { 141 | "data" : self.objects[self.offset: self.offset+self.limit], 142 | "metadata": metadata 143 | }, 144 | }, 145 | cls=MockObjectJsonEncoder) 146 | 147 | 148 | class MockMultipleRequestHTTPConnection(MockHTTPConnection): 149 | def __init__(self, statuses): 150 | super(MockMultipleRequestHTTPConnection, self).__init__() 151 | self.statuses = statuses 152 | self.status_iterator = iter(statuses) 153 | self.requests = 0 154 | self.status = None 155 | 156 | def read(self): 157 | response = {'foo': 'bar'} 158 | return json.dumps({"stat":"OK", "response":response}, 159 | cls=MockObjectJsonEncoder) 160 | 161 | def request(self, method, uri, body, headers): 162 | self.requests += 1 163 | self.status = next(self.status_iterator) 164 | super(MockMultipleRequestHTTPConnection, self).request( 165 | method, uri, body, headers) 166 | --------------------------------------------------------------------------------