pax_global_header00006660000000000000000000000064147553771540014534gustar00rootroot0000000000000052 comment=fa8f044d6f61d1a454520ac64b389cb42b36e259 duo_client_python-5.4.0/000077500000000000000000000000001475537715400152705ustar00rootroot00000000000000duo_client_python-5.4.0/.flake8000066400000000000000000000000741475537715400164440ustar00rootroot00000000000000[flake8] select = DUO exclude = duo_client/https_wrapper.py duo_client_python-5.4.0/.github/000077500000000000000000000000001475537715400166305ustar00rootroot00000000000000duo_client_python-5.4.0/.github/actions/000077500000000000000000000000001475537715400202705ustar00rootroot00000000000000duo_client_python-5.4.0/.github/actions/sbom-convert/000077500000000000000000000000001475537715400227065ustar00rootroot00000000000000duo_client_python-5.4.0/.github/actions/sbom-convert/action.yml000066400000000000000000000007601475537715400247110ustar00rootroot00000000000000name: Action for converting CycloneDX SBOM files to SPDX format runs: using: "composite" steps: - name: Install CycloneDX run: | wget https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.24.2/cyclonedx-linux-x64 chmod a+x cyclonedx-linux-x64 shell: bash - name: Convert SBOM run: | ./cyclonedx-linux-x64 convert --input-format json --output-format spdxjson --input-file cyclonedx-sbom.json --output-file spdx.json shell: bashduo_client_python-5.4.0/.github/workflows/000077500000000000000000000000001475537715400206655ustar00rootroot00000000000000duo_client_python-5.4.0/.github/workflows/publish.yml000066400000000000000000000021271475537715400230600ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Generate SBOM run: | pip install cyclonedx-bom==3.11.7 cyclonedx-py --e --format json -o cyclonedx-sbom.json - name: Convert SBOM uses: duosecurity/duo_client_python/.github/actions/sbom-convert@master - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* duo_client_python-5.4.0/.github/workflows/python-ci.yml000066400000000000000000000013061475537715400233220ustar00rootroot00000000000000name: Python CI on: push: branches: - master pull_request: branches: - master jobs: test: name: Python CI - test runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Dependencies run: | pip install -r requirements.txt pip install -r requirements-dev.txt - name: Lint with flake8 run: flake8 - name: Test with nose2 run: nose2 duo_client_python-5.4.0/.gitignore000066400000000000000000000001541475537715400172600ustar00rootroot00000000000000*.pyc *.pyo *.swp *~ .gitconfig MANIFEST build dist .idea env/ py3env/ .venv duo_client.egg-info *.DS_Store duo_client_python-5.4.0/LICENSE000066400000000000000000000031261475537715400162770ustar00rootroot00000000000000Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Note: The open-source component https_wrapper.py included with this distribution is under the terms of the Apache License, Version 2.0, a copy of which has been included as 'apache-license-2.0.txt'. duo_client_python-5.4.0/MANIFEST.in000066400000000000000000000003621475537715400170270ustar00rootroot00000000000000include apache-license-2.0.txt include LICENSE recursive-include examples * include tests/*.py include tests/admin/*.py include README.md include duo_client/ca_certs.pem include requirements.txt include requirements-dev.txt include spdx.json duo_client_python-5.4.0/README.md000066400000000000000000000036721475537715400165570ustar00rootroot00000000000000# Overview [![Build Status](https://github.com/duosecurity/duo_client_python/workflows/Python%20CI/badge.svg)](https://github.com/duosecurity/duo_client_python/actions) [![Issues](https://img.shields.io/github/issues/duosecurity/duo_client_python)](https://github.com/duosecurity/duo_client_python/issues) [![Forks](https://img.shields.io/github/forks/duosecurity/duo_client_python)](https://github.com/duosecurity/duo_client_python/network/members) [![Stars](https://img.shields.io/github/stars/duosecurity/duo_client_python)](https://github.com/duosecurity/duo_client_python/stargazers) [![License](https://img.shields.io/badge/License-View%20License-orange)](https://github.com/duosecurity/duo_client_python/blob/master/LICENSE) **Auth** - https://www.duosecurity.com/docs/authapi **Admin** - https://www.duosecurity.com/docs/adminapi **Accounts** - https://www.duosecurity.com/docs/accountsapi **Activity** - The activity endpoint is in public preview and subject to change ## Tested Against Python Versions * 3.7 * 3.8 * 3.9 * 3.10 * 3.11 ## Requirements Duo_client_python supports Python 3.7 and higher ## TLS 1.2 and 1.3 Support 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. # Installing Development: ``` $ git clone https://github.com/duosecurity/duo_client_python.git $ cd duo_client_python $ virtualenv .env $ source .env/bin/activate $ pip install --requirement requirements.txt $ pip install --requirement requirements-dev.txt $ python setup.py install ``` System: Install from [PyPi](https://pypi.org/project/duo-client/) ``` $ pip install duo-client ``` # Using See the `examples` folder for how to use this library. To run an example query, execute a command like the following from the repo root: ``` $ python examples/report_users_and_phones.py ``` # Testing ``` $ nose2 Example: `cd tests/admin && nose2` ``` # Linting ``` $ flake8 ``` duo_client_python-5.4.0/SECURITY.md000066400000000000000000000007631475537715400170670ustar00rootroot00000000000000Duo 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. # Security Policy ## Reporting a Vulnerability **Please do not use Github issues or pull requests to report security vulnerabilities.** 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. duo_client_python-5.4.0/apache-license-2.0.txt000066400000000000000000000261361475537715400211770ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. duo_client_python-5.4.0/duo_client/000077500000000000000000000000001475537715400174155ustar00rootroot00000000000000duo_client_python-5.4.0/duo_client/__init__.py000066400000000000000000000002471475537715400215310ustar00rootroot00000000000000from .accounts import Accounts from .admin import Admin from .auth import Auth from .client import __version__ __all__ = [ 'Accounts', 'Admin', 'Auth', ] duo_client_python-5.4.0/duo_client/accounts.py000066400000000000000000000030751475537715400216130ustar00rootroot00000000000000""" Duo Security Accounts API reference client implementation. """ from . import client class Accounts(client.Client): child_map = {} def get_child_accounts(self): """ Return a list of all child accounts of the integration's account. """ params = {} response = self.json_api_call('POST', '/accounts/v1/account/list', params) if response and isinstance(response, list): for account in response: account_id = account.get('account_id', None) api_hostname = account.get('api_hostname', None) if account_id and api_hostname: Accounts.child_map[account_id] = api_hostname return response def create_account(self, name): """ Create a new child account of the integration's account. """ params = { 'name': name, } response = self.json_api_call('POST', '/accounts/v1/account/create', params) return response def delete_account(self, account_id): """ Delete a child account of the integration's account. """ params = { 'account_id': account_id, } response = self.json_api_call('POST', '/accounts/v1/account/delete', params) return response duo_client_python-5.4.0/duo_client/admin.py000066400000000000000000004073351475537715400210730ustar00rootroot00000000000000""" Duo Security Administration API reference client implementation. USERS User objects are returned in the following format: {"username": , "user_id": , "realname": , "status": , "notes": , "last_login": |None, "phones": [, ...], "tokens": [, ...]} User status is one of: USER_STATUS_ACTIVE, USER_STATUS_BYPASS, USER_STATUS_DISABLED, USER_STATUS_LOCKED_OUT Note: USER_STATUS_LOCKED_OUT can only be set by the system. You cannot set a user to be in the USER_STATUS_LOCKED_OUT state. ENDPOINTS Endpoint objects are returned in the following format: {"username": , "email": , "epkey": , "os_family": , "os_version": , "model": , "type": , "browsers": [, "browser_version": , "flash_version": , "java_version": } PHONES Phone objects are returned in the following format: {"phone_id": , "number": , "extension": |'', "predelay": |None, "postdelay": |None, "type": |"Unknown", "platform": |"Unknown", "activated": , "sms_passcodes_sent": , "users": [, ...]} DESKTOP_TOKENS Desktop token objects are returned in the following format: {"desktoptoken_id": , "name": , "platform": |"Unknown", "type"": "Desktop Token", "users": [, ...]} TOKENS Token objects are returned in the following format: {"type": , "serial": , "token_id": , "totp_step": or null, "users": [, ...]} Token type is one of: TOKEN_HOTP_6, TOKEN_HOTP_8, TOKEN_YUBIKEY SETTINGS Settings objects are returned in the following format: {'inactive_user_expiration': |0, 'pending_deletion_days': , 'sms_message': , 'name': , 'sms_batch': , 'lockout_threshold': 'lockout_expire_duration': |0, 'sms_expiration': |0, 'log_retention_days': |0, 'sms_refresh': , 'telephony_warning_min': ', 'minimum_password_length': , 'password_requires_upper_alpha': , 'password_requires_lower_alpha': , 'password_requires_numeric': , 'password_requires_special': , 'security_checkup_enabled': , 'user_managers_can_put_users_in_bypass': , 'email_activity_notification_enabled': , 'push_activity_notification_enabled': , 'unenrolled_user_lockout_threshold': , 'enrollment_universal_prompt_enabled': , } INTEGRATIONS Integration objects are returned in the following format: {'adminapi_admins': , 'adminapi_info': , 'adminapi_integrations': , 'adminapi_read_log': , 'adminapi_read_resource': , 'adminapi_settings': , 'adminapi_write_resource': , 'self_service_allowed': , 'enroll_policy': , 'username_normalization_policy': , 'greeting': , 'integration_key': , 'name': , 'notes': , 'secret_key': , 'type': , 'visual_style': Deprecated; ignored if specified.} See the adminapi docs for possible values for enroll_policy, ip_whitelist, and type. ADMINISTRATIVE UNITS Administrative unit objects are returned in the following format: {'admin_unit_id': , 'name': , 'description': , 'restrict_by_groups': , 'restrict_by_integrations': , 'admins': [, ...], 'groups': [, ...], 'integrations': [, ...], } ERRORS Methods will raise a RuntimeError when an error is encountered. When the call returns a HTTP status other than 200, error will be populated with the following fields: message - String description of the error encountered such as "Received 404 Not Found" status - HTTP status such as 404 (int) reason - Status description such as "Not Found" data - Detailed error code such as {"code": 40401, "message": "Resource not found", "stat": "FAIL"} """ import base64 import json import time import urllib.parse import warnings from datetime import datetime, timedelta, timezone from . import Accounts, client from .logs.telephony import Telephony USER_STATUS_ACTIVE = "active" USER_STATUS_BYPASS = "bypass" USER_STATUS_DISABLED = "disabled" USER_STATUS_LOCKED_OUT = "locked out" TOKEN_HOTP_6 = "h6" TOKEN_HOTP_8 = "h8" TOKEN_YUBIKEY = "yk" VALID_AUTHLOG_REQUEST_PARAMS = [ "mintime", "maxtime", "limit", "sort", "next_offset", "event_types", "reasons", "results", "users", "applications", "groups", "factors", "api_version", "assessment", "detections", ] VALID_ACTIVITY_REQUEST_PARAMS = ["mintime", "maxtime", "limit", "sort", "next_offset"] class Admin(client.Client): account_id = None def api_call(self, method, path, params): if self.account_id is not None: params['account_id'] = self.account_id return super(Admin, self).api_call( method, path, params, ) @classmethod def _canonicalize_ip_whitelist(klass, ip_whitelist): if isinstance(ip_whitelist, str): return ip_whitelist else: return ','.join(ip_whitelist) pass @staticmethod def _canonicalize_bypass_codes(codes): if isinstance(codes, str): return codes else: return ','.join([str(int(code)) for code in codes]) def get_administrative_units(self, admin_id=None, group_id=None, integration_key=None, limit=None, offset=0): """ Retrieves a list of administrative units optionally filtered by admin, group, or integration. At most one of admin_id, group_id, or integration_key should be passed. Args: admin_id(str): id of admin (optional) group_id(str): id of group (optional) integration_key(str): id of integration (optional) limit: The max number of administrative units to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of administrative units Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) params = {} if admin_id is not None: params['admin_id'] = admin_id if group_id is not None: params['group_id'] = group_id if integration_key is not None: params['integration_key'] = integration_key if limit: params['limit'] = limit params['offset'] = offset return self.json_api_call('GET', '/admin/v1/administrative_units', params) iterator = self.get_administrative_units_iterator( admin_id, group_id, integration_key) return list(iterator) def get_administrative_units_iterator(self, admin_id=None, group_id=None, integration_key=None, ): """ Provides a generator which produces administrative_units. Under the hood, this generator uses pagination, so it will only store one page of administrative_units at a time in memory. Returns: A generator which produces administrative_units. Raises RuntimeError on error. """ params = {} if admin_id is not None: params['admin_id'] = admin_id if group_id is not None: params['group_id'] = group_id if integration_key is not None: params['integration_key'] = integration_key return self.json_paging_api_call('GET', '/admin/v1/administrative_units', params) def get_administrator_log(self, mintime=0): """ Returns administrator log events. mintime - Fetch events only >= mintime (to avoid duplicate records that have already been fetched) Returns: [ {'timestamp': , 'eventtype': "administrator", 'host': , 'username': , 'action': , 'object': |None, 'description': |None}, ... ] is one of: 'admin_login', 'admin_create', 'admin_update', 'admin_delete', 'customer_update', 'group_create', 'group_update', 'group_delete', 'integration_create', 'integration_update', 'integration_delete', 'phone_create', 'phone_update', 'phone_delete', 'user_create', 'user_update', 'user_delete' Raises RuntimeError on error. """ # Sanity check mintime as unix timestamp, then transform to string mintime = str(int(mintime)) params = { 'mintime': mintime, } response = self.json_api_call( 'GET', '/admin/v1/logs/administrator', params, ) for row in response: row['eventtype'] = 'administrator' row['host'] = self.host return response def get_offline_log(self, mintime=0): """ Returns offline enrollment log events. mintime - Fetch events only >= mintime (to avoid duplicate records that have already been fetched) Returns: [ {'timestamp': , 'username': , 'action': , 'object': |None, 'description': |None}, ... ] is one of: 'o2fa_user_provisioned', 'o2fa_user_deprovisioned', 'o2fa_user_reenrolled' Raises RuntimeError on error. """ # Sanity check mintime as unix timestamp, then transform to string mintime = str(int(mintime)) params = { 'mintime': mintime, } response = self.json_api_call( 'GET', '/admin/v1/logs/offline_enrollment', params, ) return response def get_authentication_log(self, api_version=1, **kwargs): """ Returns authentication log events. api_version - The api version of the handler to use. Currently, the default api version is v1, but the v1 api will be deprecated in a future version of the Duo Admin API. Please migrate to the v2 api at your earliest convenience. For details on the differences between v1 and v2, please see Duo's Admin API documentation. (Optional) API Version v1: mintime - Fetch events only >= mintime (to avoid duplicate records that have already been fetched) Returns: [ {'timestamp': , 'eventtype': "authentication", 'host': , 'username': , 'factor': , 'result': , 'ip': , 'new_enrollment': , 'integration': , 'location': { 'state': '', 'city': '', 'country': '' } }] Raises RuntimeError on error. API Version v2: mintime (required) - Unix timestamp in ms; fetch records >= mintime maxtime (required) - Unix timestamp in ms; fetch records <= maxtime limit - Number of results to limit to next_offset - Used to grab the next set of results from a previous response sort - Sort order to be applied users - List of user ids to filter on groups - List of group ids to filter on applications - List of application ids to filter on results - List of results to filter to filter on reasons - List of reasons to filter to filter on factors - List of factors to filter on event_types - List of event_types to filter on Returns: { "authlogs": [ { "access_device": { "ip": , "location": { "city": , "state": , "country": , "name": }, "auth_device": { "ip": , "location": { "city": , "state": , "country": }, "event_type": , "factor": , "result": , "timestamp": , "user": { "key": , "name": } } ], "metadata": { "next_offset": [ , ], "total_objects": } } Raises RuntimeError on error. """ if api_version not in [1,2]: raise ValueError("Invalid API Version") params = {} if api_version == 1: #v1 params['mintime'] = kwargs['mintime'] if 'mintime' in kwargs else 0; # Sanity check mintime as unix timestamp, then transform to string params['mintime'] = '{:d}'.format(int(params['mintime'])) warnings.warn( 'The v1 Admin API for retrieving authentication log events ' 'will be deprecated in a future release of the Duo Admin API. ' 'Please migrate to the v2 API.', DeprecationWarning) else: #v2 for k in kwargs: if kwargs[k] is not None and k in VALID_AUTHLOG_REQUEST_PARAMS: params[k] = kwargs[k] if 'mintime' not in params: params['mintime'] = (int(time.time()) - 86400) * 1000 # Sanity check mintime as unix timestamp, then transform to string params['mintime'] = '{:d}'.format(int(params['mintime'])) if 'maxtime' not in params: params['maxtime'] = int(time.time()) * 1000 # Sanity check maxtime as unix timestamp, then transform to string params['maxtime'] = '{:d}'.format(int(params['maxtime'])) response = self.json_api_call( 'GET', '/admin/v{}/logs/authentication'.format(api_version), params, ) if api_version == 1: for row in response: row['eventtype'] = 'authentication' row['host'] = self.host else: for row in response['authlogs']: row['eventtype'] = 'authentication' row['host'] = self.host return response def get_activity_logs(self, **kwargs): """ Returns activity log events. The activity endpoint is in public preview and subject to change. mintime - Unix timestamp in ms; fetch records >= mintime maxtime - Unix timestamp in ms; fetch records <= maxtime limit - Number of results to limit to next_offset - Used to grab the next set of results from a previous response sort - Sort order to be applied Returns: { "items": [ { "access_device": { "browser": , "browser_version": , "ip": { "address": }, "location": { "city": , "country": , "state": }, "os": , "os_version": }, "action": , "activity_id": , "actor": { "details": "key" : , "name": , "type": }, "akey": , "application": { "key": , "name": , "type": }, "target": { "details": , "key" : , "name": , "type": }, "ts": }, ], "metadata": { "next_offset": "total_objects": { "relation" : "value" : } } } Raises RuntimeError on error. """ params = {} today = datetime.now(tz=timezone.utc) default_maxtime = int(today.timestamp() * 1000) default_mintime = int((today - timedelta(days=180)).timestamp() * 1000) for k in kwargs: if kwargs[k] is not None and k in VALID_ACTIVITY_REQUEST_PARAMS: params[k] = kwargs[k] if 'mintime' not in params: # If mintime is not provided, the script defaults it to 180 days in past params['mintime'] = default_mintime params['mintime'] = str(int(params['mintime'])) if 'maxtime' not in params: #if maxtime is not provided, the script defaults it to now params['maxtime'] = default_maxtime params['maxtime'] = str(int(params['maxtime'])) if 'limit' in params: params['limit'] = str(int(params['limit'])) response = self.json_api_call( 'GET', '/admin/v2/logs/activity', params, ) for row in response['items']: row['eventtype'] = 'activity' row['host'] = self.host return response def get_telephony_log(self, mintime=0, api_version=1, **kwargs): """ Returns telephony log events. mintime - Fetch events only >= mintime (to avoid duplicate records that have already been fetched) api_version - The API version of the handler to use. Currently, the default api version is v1, but the v1 API will be deprecated in a future version of the Duo Admin API. Please migrate to the v2 api at your earliest convenience. For details on the differences between v1 and v2, please see Duo's Admin API documentation. (Optional) v1 Returns: [ { 'timestamp': , 'eventtype': "telephony", 'host': , 'context': , 'type': , 'phone': , 'credits': } ] v2 Returns: { "items": [ { 'context': , 'credits': , 'phone': , 'telephony_id': , 'ts': , 'txid': , 'type': , 'eventtype': , 'host': } ], "metadata": { "next_offset": "total_objects": { "relation" : "value" : } } } Raises RuntimeError on error. """ if api_version not in [1,2]: raise ValueError("Invalid API Version") if api_version == 2: return Telephony.get_telephony_logs_v2(self.json_api_call, self.host, **kwargs) return Telephony.get_telephony_logs_v1(self.json_api_call, self.host, mintime=mintime) def get_users_iterator(self): """ Returns iterator of user objects. Raises RuntimeError on error. """ return self.json_paging_api_call('GET', '/admin/v1/users', {}) def get_users(self, limit=None, offset=0): """ Returns a list of user objects. Params: limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/users', {'limit': limit, 'offset': offset}) return list(self.get_users_iterator()) def get_user_by_id(self, user_id): """ Returns user specified by user_id. user_id - User to fetch Returns user object. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id response = self.json_api_call('GET', path, {}) return response def get_user_by_email(self, email): """ Returns user specified by email. email - User to fetch Returns user object. Raises RuntimeError on error. """ params = { 'email': email, } response = self.json_api_call('GET', '/admin/v1/users', params) return response def get_users_by_name(self, username): """ Returns user specified by username. username - User to fetch Returns a list of 0 or 1 user objects. Raises RuntimeError on error. """ params = { 'username': username, } response = self.json_api_call('GET', '/admin/v1/users', params) return response def get_users_by_names(self, usernames): """ Returns users specified by usernames. usernames - Users to fetch Returns a list user objects matching usernames (or aliases). Raises RuntimeError on error. """ username_list = json.dumps(usernames) params = { 'username_list': username_list, } response = self.json_paging_api_call('GET', '/admin/v1/users', params) return response def get_users_by_ids(self, user_ids): """ Returns users specified by user ids. user_ids - Users to fetch Returns a list user objects matching user ids. Raises RuntimeError on error. """ user_id_list = json.dumps(user_ids) params = { 'user_id_list': user_id_list, } response = self.json_paging_api_call('GET', '/admin/v1/users', params) return response def add_user(self, username, realname=None, status=None, notes=None, email=None, firstname=None, lastname=None, alias1=None, alias2=None, alias3=None, alias4=None, aliases=None): """ Adds a user. username - Username realname - User's real name (optional) status - User's status, defaults to USER_STATUS_ACTIVE notes - Comment field (optional) email - Email address (optional) firstname - User's given name for ID Proofing (optional) lastname - User's surname for ID Proofing (optional) alias1..alias4 - Aliases for the user's primary username (optional) aliases - Aliases for the user's primary username (optional) Returns newly created user object. Raises RuntimeError on error. """ params = { 'username': username, } if realname is not None: params['realname'] = realname if status is not None: params['status'] = status if notes is not None: params['notes'] = notes if email is not None: params['email'] = email if firstname is not None: params['firstname'] = firstname if lastname is not None: params['lastname'] = lastname if alias1 is not None: params['alias1'] = alias1 if alias2 is not None: params['alias2'] = alias2 if alias3 is not None: params['alias3'] = alias3 if alias4 is not None: params['alias4'] = alias4 if aliases is not None: params['aliases'] = aliases response = self.json_api_call('POST', '/admin/v1/users', params) return response def update_user(self, user_id, username=None, realname=None, status=None, notes=None, email=None, firstname=None, lastname=None, alias1=None, alias2=None, alias3=None, alias4=None, aliases=None): """ Update username, realname, status, or notes for a user. user_id - User ID username - Username (optional) realname - User's real name (optional) status - User's status, defaults to USER_STATUS_ACTIVE notes - Comment field (optional) email - Email address (optional) firstname - User's given name for ID Proofing (optional) lastname - User's surname for ID Proofing (optional) alias1..alias4 - Aliases for the user's primary username (optional) aliases - Aliases for the user's primary username. (optional) Returns updated user object. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id params = {} if username is not None: params['username'] = username if realname is not None: params['realname'] = realname if status is not None: params['status'] = status if notes is not None: params['notes'] = notes if email is not None: params['email'] = email if firstname is not None: params['firstname'] = firstname if lastname is not None: params['lastname'] = lastname if alias1 is not None: params['alias1'] = alias1 if alias2 is not None: params['alias2'] = alias2 if alias3 is not None: params['alias3'] = alias3 if alias4 is not None: params['alias4'] = alias4 if aliases is not None: params['aliases'] = aliases response = self.json_api_call('POST', path, params) return response def delete_user(self, user_id): """ Deletes a user. If the user is already deleted, does nothing. user_id - User ID Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id return self.json_api_call('DELETE', path, {}) def enroll_user(self, username, email, valid_secs=None): """ Enroll a user and send them an enrollment email. username - Username email - Email address valid_secs - Seconds before the enrollment link expires (if 0 it never expires) Returns nothing. Raises RuntimeError on error. """ path = '/admin/v1/users/enroll' params = { 'username': username, 'email': email, } if valid_secs is not None: params['valid_secs'] = str(valid_secs) return self.json_api_call('POST', path, params) def add_user_bypass_codes( self, user_id, count=None, valid_secs=None, remaining_uses=None, codes=None, preserve_existing=None, endpoint_verification=None, ): """ Generate bypass codes for user. Replaces a user's bypass codes with new codes unless `preserve_existing=True` is passed. user_id User ID count Number of new codes to randomly generate valid_secs Seconds before codes expire (if 0 they will never expire) remaining_uses The number of times this code can be used (0 is unlimited) codes Optionally provide custom codes, otherwise will be random count and codes are mutually exclusive preserve_existing whether to preserve existing codes when creating new ones, default is to remove existing bypass codes endpoint_verification New argument for unreleased feature. Will be ignored if used. Client will be updated again in the future when feature is released. Returns a list of newly created codes. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/bypass_codes' params = {} if count is not None: params['count'] = str(int(count)) if valid_secs is not None: params['valid_secs'] = str(int(valid_secs)) if endpoint_verification is not None: params["endpoint_verification"] = str(endpoint_verification).lower() if remaining_uses is not None: params['reuse_count'] = str(int(remaining_uses)) if codes is not None: params['codes'] = self._canonicalize_bypass_codes(codes) if preserve_existing is not None: params['preserve_existing'] = preserve_existing return self.json_api_call('POST', path, params) def get_user_bypass_codes_iterator(self, user_id): """ Returns an iterator of bypass codes associated with a user. Params: user_id (str) - The user id. Returns: A iterator yielding bypass code dicts. Notes: Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/bypass_codes' return self.json_paging_api_call('GET', path, {}) def get_user_bypass_codes(self, user_id, limit=None, offset=0): """ Returns a list of bypass codes associated with a user. Params: user_id (str) - The user id. limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Returns: An array of bypass code dicts. Notes: Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/bypass_codes' return self.json_api_call( 'GET', path, {'limit': limit, 'offset': offset}) return list(self.get_user_bypass_codes_iterator(user_id)) def get_user_phones_iterator(self, user_id): """ Returns an iterator of phones associated with the user. user_id - User ID Returns an iterator of phone objects. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/phones' return self.json_paging_api_call('GET', path, {}) def get_user_phones(self, user_id, limit=None, offset=0): """ Returns an array of phones associated with the user. user_id - User ID limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Returns list of phone objects. Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/phones' return self.json_api_call( 'GET', path, {'limit': limit, 'offset': offset}) return list(self.get_user_phones_iterator(user_id)) def add_user_phone(self, user_id, phone_id): """ Associates a phone with a user. user_id - User ID phone_id - Phone ID Returns nothing. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/phones' params = { 'phone_id': phone_id, } return self.json_api_call('POST', path, params) def delete_user_phone(self, user_id, phone_id): """ Dissociates a phone from a user. user_id - User ID phone_id - Phone ID Returns nothing. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/phones/' + phone_id params = {} return self.json_api_call('DELETE', path, params) def get_user_tokens_iterator(self, user_id): """ Returns an iterator of hardware tokens associated with the user. user_id - User ID Returns iterator of hardware token objects. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/tokens' return self.json_paging_api_call('GET', path, {}) def get_user_tokens(self, user_id, limit=None, offset=0): """ Returns an array of hardware tokens associated with the user. user_id - User ID limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Returns list of hardware token objects. Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/tokens' return self.json_api_call( 'GET', path, {'limit': limit, 'offset': offset}) return list(self.get_user_tokens_iterator(user_id)) def add_user_token(self, user_id, token_id): """ Associates a hardware token with a user. user_id - User ID token_id - Token ID Returns nothing. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/tokens' params = { 'token_id': token_id, } return self.json_api_call('POST', path, params) def delete_user_token(self, user_id, token_id): """ Dissociates a hardware token from a user. user_id - User ID token_id - Hardware token ID Returns nothing. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) token_id = urllib.parse.quote_plus(str(token_id)) path = '/admin/v1/users/' + user_id + '/tokens/' + token_id return self.json_api_call('DELETE', path, {}) def get_user_u2ftokens_iterator(self, user_id): """ Returns an iterator of u2ftokens associated with a user. Params: user_id (str) - The user id. Returns: A generator yielding u2ftoken dicts. Notes: Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/u2ftokens' return self.json_paging_api_call('GET', path, {}) def get_user_u2ftokens(self, user_id, limit=None, offset=0): """ Returns an array of u2ftokens associated with a user. Params: user_id (str) - The user id. limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Returns: An array of u2ftoken dicts. Notes: Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/u2ftokens' return self.json_api_call( 'GET', path, {'limit': limit, 'offset': offset}) return list(self.get_user_u2ftokens_iterator(user_id)) def get_user_webauthncredentials_iterator(self, user_id): """ Returns an iterator of webauthncredentials associated with a user. Params: user_id (str) - The user id. Returns: A generator yielding webauthncredentials dicts. Notes: Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/webauthncredentials' return self.json_paging_api_call('GET', path, {}) def get_user_webauthncredentials(self, user_id, limit=None, offset=0): """ Returns an array of webauthncredentials associated with a user. Params: user_id (str) - The user id. limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Returns: An array of webauthncredentials dicts. Notes: Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/webauthncredentials' return self.json_api_call( 'GET', path, {'limit': limit, 'offset': offset}) return list(self.get_user_webauthncredentials_iterator(user_id)) def get_user_groups_iterator(self, user_id): """ Returns an iterator of groups associated with the user. user_id - User ID Returns iterator of groups objects. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/groups' return self.json_paging_api_call('GET', path, {}) def get_user_groups(self, user_id, limit=None, offset=0): """ Returns an array of groups associated with the user. user_id - User ID limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Returns list of groups objects. Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/groups' return self.json_api_call( 'GET', path, {'limit': limit, 'offset': offset}) return list(self.get_user_groups_iterator(user_id)) def add_user_group(self, user_id, group_id): """ Associates a group with a user. user_id - User ID group_id - Group ID Returns nothing. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/groups' params = {'group_id': group_id} return self.json_api_call('POST', path, params) def delete_user_group(self, user_id, group_id): """ Dissociates a group from a user. user_id - User ID group_id - Group ID Returns nothing. Raises RuntimeError on error. """ user_id = urllib.parse.quote_plus(str(user_id)) path = '/admin/v1/users/' + user_id + '/groups/' + group_id params = {} return self.json_api_call('DELETE', path, params) def get_endpoint(self, epkey): """ Get a single endpoint from the AdminAPI by a supplied epkey. Params: epkey (str) - The supplied endpoint key to fetch. Returns: The endpoint object represented as a dict. Raises: RuntimeError: if the request returns a non-200 status response. """ escaped_epkey = urllib.parse.quote_plus(str(epkey)) path = '/admin/v1/endpoints/' + escaped_epkey return self.json_api_call('GET', path, {}) def get_endpoints_iterator(self): """ Returns iterator of endpoints objects. Raises RuntimeError on error. """ return self.json_paging_api_call('GET', '/admin/v1/endpoints', {}) def get_endpoints(self, limit=None, offset=0): """ Returns a list of endpoints. Params: limit - The maximum number of records to return. (Optional) offset - The offset of the first record to return. (Optional) Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call('GET', '/admin/v1/endpoints', {'limit': limit, 'offset': offset}) return list(self.get_endpoints_iterator()) def get_phones_generator(self): """ Returns a generator yielding phones. """ return self.json_paging_api_call( 'GET', '/admin/v1/phones', {} ) def get_phones(self, limit=None, offset=0): """ Retrieves a list of phones. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of phones Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/phones', {'limit': limit, 'offset': offset} ) return list(self.get_phones_generator()) def get_phone_by_id(self, phone_id): """ Returns a phone specified by phone_id. phone_id - Phone ID Returns phone object. Raises RuntimeError on error. """ path = '/admin/v1/phones/' + phone_id response = self.json_api_call('GET', path, {}) return response def get_phones_by_number(self, number, extension=None): """ Returns a phone specified by number and extension. number - Phone number extension - Phone number extension (optional) Returns list of 0 or 1 phone objects. Raises RuntimeError on error. """ path = '/admin/v1/phones' params = {'number': number} if extension is not None: params['extension'] = extension response = self.json_api_call('GET', path, params) return response def add_phone(self, number=None, extension=None, name=None, type=None, platform=None, predelay=None, postdelay=None): """ Adds a phone. number - Phone number (optional). extension - Phone number extension (optional). name - Phone name (optional). type - The phone type (optional). platform - The phone platform (optional). predelay - Number of seconds to wait after the number picks up before dialing the extension (optional). postdelay - Number of seconds to wait after the extension is dialed before the speaking the prompt (optional). Returns phone object. Raises RuntimeError on error. """ path = '/admin/v1/phones' params = {} if number is not None: params['number'] = number if extension is not None: params['extension'] = extension if name is not None: params['name'] = name if type is not None: params['type'] = type if platform is not None: params['platform'] = platform if predelay is not None: params['predelay'] = predelay if postdelay is not None: params['postdelay'] = postdelay response = self.json_api_call('POST', path, params) return response def update_phone(self, phone_id, number=None, extension=None, name=None, type=None, platform=None, predelay=None, postdelay=None): """ Update a phone. number - Phone number (optional) extension - Phone number extension (optional). name - Phone name (optional). type - The phone type (optional). platform - The phone platform (optional). predelay - Number of seconds to wait after the number picks up before dialing the extension (optional). postdelay - Number of seconds to wait after the extension is dialed before the speaking the prompt (optional). Returns phone object. Raises RuntimeError on error. """ phone_id = urllib.parse.quote_plus(str(phone_id)) path = '/admin/v1/phones/' + phone_id params = {} if number is not None: params['number'] = number if extension is not None: params['extension'] = extension if name is not None: params['name'] = name if type is not None: params['type'] = type if platform is not None: params['platform'] = platform if predelay is not None: params['predelay'] = predelay if postdelay is not None: params['postdelay'] = postdelay response = self.json_api_call('POST', path, params) return response def delete_phone(self, phone_id): """ Deletes a phone. If the phone has already been deleted, does nothing. phone_id - Phone ID. Raises RuntimeError on error. """ path = '/admin/v1/phones/' + phone_id params = {} return self.json_api_call('DELETE', path, params) def send_sms_activation_to_phone(self, phone_id, valid_secs=None, install=None, installation_msg=None, activation_msg=None): """ Generate a Duo Mobile activation code and send it to the phone via SMS, optionally sending an additional message with a PATH to install Duo Mobile. phone_id - Phone ID. valid_secs - The number of seconds activation code should be valid for. Default: 86400 seconds (one day). install - '1' to also send an installation SMS message before the activation message; '0' to not send. Default: '0'. installation_msg - Custom installation message template to send to the user if install was 1. Must contain , which is replaced with the installation URL. activation_msg - Custom activation message template. Must contain , which is replaced with the activation URL. Returns: { "activation_barcode": "https://api-abcdef.duosecurity.com/frame/qr?value=duo%3A%2F%2Factivation-code", "activation_msg": "To activate the Duo Mobile app, click this link: https://m-abcdef.duosecurity.com/iphone/7dmi4Oowz5g3J47FARLs", "installation_msg": "Welcome to Duo! To install the Duo Mobile app, click this link: http://m-abcdef.duosecurity.com", "valid_secs": 3600 } Raises RuntimeError on error. """ path = '/admin/v1/phones/' + phone_id + '/send_sms_activation' params = {} if valid_secs is not None: params['valid_secs'] = str(valid_secs) if install is not None: params['install'] = str(int(bool(install))) if installation_msg is not None: params['installation_msg'] = installation_msg if activation_msg is not None: params['activation_msg'] = activation_msg return self.json_api_call('POST', path, params) def create_activation_url(self, phone_id, valid_secs=None, install=None): """ Create an activation code for Duo Mobile. phone_id - Phone ID. valid_secs - The number of seconds activation code should be valid for. Default: 86400 seconds (one day). install - '1' to also return an installation_url for Duo Mobile; '0' to not return. Default: '0'. Returns: { "activation_barcode": "https://api-abcdef.duosecurity.com/frame/qr?value=duo%3A%2F%2Factivation-code", "activation_url": "https://m-abcdef.duosecurity.com/iphone/7dmi4Oowz5g3J47FARLs", "valid_secs": 3600 } Raises RuntimeError on error. """ path = '/admin/v1/phones/' + phone_id + '/activation_url' params = {} if valid_secs is not None: params['valid_secs'] = str(valid_secs) if install is not None: params['install'] = str(int(bool(install))) return self.json_api_call('POST', path, params) def send_sms_installation(self, phone_id, installation_msg=None): """ Send a message via SMS describing how to install Duo Mobile. phone_id - Phone ID. installation_msg - Custom installation message template to send to the user if install was 1. Must contain , which is replaced with the installation URL. Returns: { "installation_msg": "Welcome to Duo! To install the Duo Mobile app, click this link: http://m-abcdef.duosecurity.com", } Raises RuntimeError on error. """ path = '/admin/v1/phones/' + phone_id + '/send_sms_installation' params = {} if installation_msg is not None: params['installation_msg'] = installation_msg return self.json_api_call('POST', path, params) def get_desktoptokens_generator(self): """ Returns a generator yielding desktoptokens. """ return self.json_paging_api_call( 'GET', '/admin/v1/desktoptokens', {} ) def get_desktoptokens(self, limit=None, offset=0): """ Retrieves a list of desktoptokens. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of desktoptokens Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/desktoptokens', {'limit': limit, 'offset': offset} ) return list(self.get_desktoptokens_generator()) def get_desktoptoken_by_id(self, desktoptoken_id): """ Returns a desktop token specified by dtoken_id. desktoptoken_id - Desktop Token ID Returns desktop token object. Raises RuntimeError on error. """ path = '/admin/v1/desktoptokens/' + desktoptoken_id response = self.json_api_call('GET', path, {}) return response def add_desktoptoken(self, platform, name=None): """ Adds a desktop token. Returns desktop token object. platform - The desktop token platform. name - Desktop token name (optional). Raises RuntimeError on error. """ params = { 'platform': platform, } if name is not None: params['name'] = name response = self.json_api_call('POST', '/admin/v1/desktoptokens', params) return response def delete_desktoptoken(self, desktoptoken_id): """ Deletes a desktop token. If the desktop token has already been deleted, does nothing. desktoptoken_id - Desktop token ID. Returns nothing. Raises RuntimeError on error. """ path = '/admin/v1/desktoptokens/' + urllib.parse.quote_plus(desktoptoken_id) params = {} return self.json_api_call('DELETE', path, params) def update_desktoptoken(self, desktoptoken_id, platform=None, name=None): """ Update a desktop token. Returns desktop token object. name - Desktop token name (optional). platform - The desktop token platform (optional). Raises RuntimeError on error. """ desktoptoken_id = urllib.parse.quote_plus(str(desktoptoken_id)) path = '/admin/v1/desktoptokens/' + desktoptoken_id params = {} if platform is not None: params['platform'] = platform if name is not None: params['name'] = name response = self.json_api_call('POST', path, params) return response def activate_desktoptoken(self, desktoptoken_id, valid_secs=None): """ Generates an activation code for a desktop token. Returns activation info like: { 'activation_msg': , 'activation_url': , 'valid_secs': } Raises RuntimeError on error. """ params = {} if valid_secs: params['valid_secs'] = str(valid_secs) quoted_id = urllib.parse.quote_plus(desktoptoken_id) response = self.json_api_call('POST', '/admin/v1/desktoptokens/%s/activate' % quoted_id, params) return response def get_tokens_generator(self): """ Returns a generator yielding tokens. """ return self.json_paging_api_call( 'GET', '/admin/v1/tokens', {} ) def get_tokens(self, limit=None, offset=0): """ Retrieves a list of tokens. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of tokens Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/tokens', {'limit': limit, 'offset': offset} ) return list(self.get_tokens_generator()) def get_token_by_id(self, token_id): """ Returns a token. token_id - Token ID Returns a token object. """ token_id = urllib.parse.quote_plus(str(token_id)) path = '/admin/v1/tokens/' + token_id params = {} response = self.json_api_call('GET', path, params) return response def get_tokens_by_serial(self, type, serial): """ Returns a token. type - Token type, one of TOKEN_HOTP_6, TOKEN_HOTP_8, TOKEN_YUBIKEY serial - Token serial number Returns a list of 0 or 1 token objects. """ params = { 'type': type, 'serial': serial, } response = self.json_api_call('GET', '/admin/v1/tokens', params) return response def delete_token(self, token_id): """ Deletes a token. If the token is already deleted, does nothing. token_id - Token ID """ token_id = urllib.parse.quote_plus(str(token_id)) path = '/admin/v1/tokens/' + token_id return self.json_api_call('DELETE', path, {}) def add_hotp6_token(self, serial, secret, counter=None): """ Add a HOTP6 token. serial - Token serial number secret - HOTP secret counter - Initial counter value (default: 0) Returns newly added token object. """ path = '/admin/v1/tokens' params = {'type': 'h6', 'serial': serial, 'secret': secret} if counter is not None: params['counter'] = str(int(counter)) response = self.json_api_call('POST', path, params) return response def add_hotp8_token(self, serial, secret, counter=None): """ Add a HOTP8 token. serial - Token serial number secret - HOTP secret counter - Initial counter value (default: 0) Returns newly added token object. """ path = '/admin/v1/tokens' params = {'type': 'h8', 'serial': serial, 'secret': secret} if counter is not None: params['counter'] = str(int(counter)) response = self.json_api_call('POST', path, params) return response def add_totp6_token(self, serial, secret, totp_step=None): """ Add a TOTP6 token. serial - Token serial number secret - TOTP secret totp_step - Time step (default: 30 seconds) Returns newly added token object. """ path = '/admin/v1/tokens' params = {'type': 't6', 'serial': serial, 'secret': secret} if totp_step is not None: params['totp_step'] = str(int(totp_step)) response = self.json_api_call('POST', path, params) return response def add_totp8_token(self, serial, secret, totp_step=None): """ Add a TOTP8 token. serial - Token serial number secret - TOTP secret totp_step - Time step (default: 30 seconds) Returns newly added token object. """ path = '/admin/v1/tokens' params = {'type': 't8', 'serial': serial, 'secret': secret} if totp_step is not None: params['totp_step'] = str(int(totp_step)) response = self.json_api_call('POST', path, params) return response def update_token(self, token_id, totp_step=None): """ Update a token. totp_step - Time step (optional) Returns token object. Raises RuntimeError on error. """ token_id = urllib.parse.quote_plus(str(token_id)) path = '/admin/v1/tokens/' + token_id params = {} if totp_step is not None: params['totp_step'] = totp_step response = self.json_api_call('POST', path, params) return response def add_yubikey_token(self, serial, private_id, aes_key): """ Add a Yubikey AES token. serial - Token serial number secret - HOTP secret Returns newly added token object. """ path = '/admin/v1/tokens' params = {'type': 'yk', 'serial': serial, 'private_id': private_id, 'aes_key': aes_key} response = self.json_api_call('POST', path, params) return response def resync_hotp_token(self, token_id, code1, code2, code3): """ Resync HOTP counter. The user must generate 3 consecutive OTP from their token and input them as code1, code2, and code3. This function will scan ahead in the OTP sequence to find a counter that resyncs with those 3 codes. token_id - Token ID code1 - First OTP from token code2 - Second OTP from token code3 - Third OTP from token Returns nothing on success. """ token_id = urllib.parse.quote_plus(str(token_id)) path = '/admin/v1/tokens/' + token_id + '/resync' params = {'code1': code1, 'code2': code2, 'code3': code3} return self.json_api_call('POST', path, params) def get_settings(self): """ Returns customer settings. Returns a settings object. Raises RuntimeError on error. """ return self.json_api_call('GET', '/admin/v1/settings', {}) def update_settings(self, lockout_threshold=None, lockout_expire_duration=None, inactive_user_expiration=None, pending_deletion_days=None, log_retention_days=None, sms_batch=None, sms_expiration=None, sms_refresh=None, sms_message=None, fraud_email=None, fraud_email_enabled=None, keypress_confirm=None, keypress_fraud=None, timezone=None, telephony_warning_min=None, caller_id=None, push_enabled=None, voice_enabled=None, sms_enabled=None, mobile_otp_enabled=None, u2f_enabled=None, user_telephony_cost_max=None, minimum_password_length=None, password_requires_upper_alpha=None, password_requires_lower_alpha=None, password_requires_numeric=None, password_requires_special=None, helpdesk_bypass=None, helpdesk_bypass_expiration=None, helpdesk_message=None, helpdesk_can_send_enroll_email=None, reactivation_url=None, reactivation_integration_key=None, security_checkup_enabled=None, user_managers_can_put_users_in_bypass=None, email_activity_notification_enabled=None, push_activity_notification_enabled=None, unenrolled_user_lockout_threshold=None, enrollment_universal_prompt_enabled=None, ): """ Update settings. lockout_threshold - |None lockout_expire_duration - |0|None inactive_user_expiration - |None pending_deletion_days - |None log_retention_days - |0|None sms_batch - |None sms_expiration - |None sms_refresh - True|False|None sms_message - |None fraud_email - |None fraud_email_enabled - True|False|None keypress_confirm - |None keypress_fraud - |None timezone - |None telephony_warning_min - caller_id - push_enabled - Deprecated; ignored if specified. sms_enabled - Deprecated; ignored if specified. voice_enabled - Deprecated; ignored if specified. mobile_otp_enabled - Deprecated; ignored if specified. u2f_enabled - Deprecated; ignored if specified. user_telephony_cost_max - minimum_password_length - |None password_requires_upper_alpha - True|False|None password_requires_lower_alpha - True|False|None password_requires_numeric - True|False|None password_requires_special - True|False|None helpdesk_bypass - "allow"|"limit"|"deny"|None helpdesk_bypass_expiration - |0 helpdesk_message - helpdesk_can_send_enroll_email - True|False|None reactivation_url - |None reactivation_integration_key - |None security_checkup_enabled - True|False|None user_managers_can_put_users_in_bypass - True|False|None email_activity_notification_enabled = True|False|None push_activity_notification_enabled = True|False|None unenrolled_user_lockout_threshold = |0|None enrollment_universal_prompt_enabled = True|False|None Returns updated settings object. Raises RuntimeError on error. """ params = {} if lockout_threshold is not None: params['lockout_threshold'] = str(lockout_threshold) if lockout_expire_duration is not None: params['lockout_expire_duration'] = str(lockout_expire_duration) if inactive_user_expiration is not None: params['inactive_user_expiration'] = str(inactive_user_expiration) if pending_deletion_days is not None: params['pending_deletion_days'] = str(pending_deletion_days) if log_retention_days is not None: params['log_retention_days'] = str(log_retention_days) if sms_batch is not None: params['sms_batch'] = str(sms_batch) if sms_expiration is not None: params['sms_expiration'] = str(sms_expiration) if sms_refresh is not None: params['sms_refresh'] = '1' if sms_refresh else '0' if sms_message is not None: params['sms_message'] = sms_message if fraud_email is not None: params['fraud_email'] = fraud_email if fraud_email_enabled is not None: params['fraud_email_enabled'] = ('1' if fraud_email_enabled else '0') if keypress_confirm is not None: params['keypress_confirm'] = keypress_confirm if keypress_fraud is not None: params['keypress_fraud'] = keypress_fraud if timezone is not None: params['timezone'] = timezone if telephony_warning_min is not None: params['telephony_warning_min'] = str(telephony_warning_min) if caller_id is not None: params['caller_id'] = caller_id if user_telephony_cost_max is not None: params['user_telephony_cost_max'] = str(user_telephony_cost_max) if minimum_password_length is not None: params['minimum_password_length'] = str(minimum_password_length) if password_requires_upper_alpha is not None: params['password_requires_upper_alpha'] = ('1' if password_requires_upper_alpha else '0') if password_requires_lower_alpha is not None: params['password_requires_lower_alpha'] = ('1' if password_requires_lower_alpha else '0') if password_requires_numeric is not None: params['password_requires_numeric'] = ('1' if password_requires_numeric else '0') if password_requires_special is not None: params['password_requires_special'] = ('1' if password_requires_special else '0') if helpdesk_bypass is not None: params['helpdesk_bypass'] = str(helpdesk_bypass) if helpdesk_bypass_expiration is not None: params['helpdesk_bypass_expiration'] = str(helpdesk_bypass_expiration) if helpdesk_message is not None: params['helpdesk_message'] = str(helpdesk_message) if helpdesk_can_send_enroll_email is not None: params['helpdesk_can_send_enroll_email'] = ('1' if helpdesk_can_send_enroll_email else '0') if reactivation_url is not None: params['reactivation_url'] = reactivation_url if reactivation_integration_key is not None: params['reactivation_integration_key'] = reactivation_integration_key if security_checkup_enabled is not None: params['security_checkup_enabled'] = ('1' if security_checkup_enabled else '0') if user_managers_can_put_users_in_bypass is not None: params['user_managers_can_put_users_in_bypass'] = ('1' if user_managers_can_put_users_in_bypass else '0') if email_activity_notification_enabled is not None: params['email_activity_notification_enabled'] = ( '1' if email_activity_notification_enabled else '0' ) if push_activity_notification_enabled is not None: params['push_activity_notification_enabled'] = ( '1' if push_activity_notification_enabled else '0' ) if unenrolled_user_lockout_threshold is not None: params['unenrolled_user_lockout_threshold'] = str( unenrolled_user_lockout_threshold ) if enrollment_universal_prompt_enabled is not None: params['enrollment_universal_prompt_enabled'] = ( '1' if enrollment_universal_prompt_enabled else '0' ) if not params: raise TypeError("No settings were provided") response = self.json_api_call('POST', '/admin/v1/settings', params) return response def set_allowed_admin_auth_methods(self, push_enabled=None, sms_enabled=None, voice_enabled=None, mobile_otp_enabled=None, yubikey_enabled=None, hardware_token_enabled=None, verified_push_enabled=None, verified_push_length=None ): params = {} if push_enabled is not None: params['push_enabled'] = ( '1' if push_enabled else '0') if sms_enabled is not None: params['sms_enabled'] = ( '1' if sms_enabled else '0') if mobile_otp_enabled is not None: params['mobile_otp_enabled'] = ( '1' if mobile_otp_enabled else '0') if hardware_token_enabled is not None: params['hardware_token_enabled'] = ( '1' if hardware_token_enabled else '0') if yubikey_enabled is not None: params['yubikey_enabled'] = ( '1' if yubikey_enabled else '0') if voice_enabled is not None: params['voice_enabled'] = ( '1' if voice_enabled else '0') if verified_push_enabled is not None: params['verified_push_enabled'] = ( '1' if verified_push_enabled else '0') if params['verified_push_enabled'] == '1': params['verified_push_length'] = ( verified_push_length if verified_push_length is not None else 3) response = self.json_api_call( 'POST', '/admin/v1/admins/allowed_auth_methods', params ) return response def get_allowed_admin_auth_methods(self): params={} response = self.json_api_call( 'GET', '/admin/v1/admins/allowed_auth_methods', params ) return response def get_info_summary(self): """ Returns a summary of objects in the account. Raises RuntimeError on error. """ params = {} response = self.json_api_call( 'GET', '/admin/v1/info/summary', params ) return response def get_info_telephony_credits_used(self, mintime=None, maxtime=None): """ Returns number of telephony credits used during the time period. mintime - Limit report to data for events after this UNIX timestamp. Defaults to thirty days ago. maxtime - Limit report to data for events before this UNIX timestamp. Defaults to the current time. Raises RuntimeError on error. """ params = {} if mintime is not None: params['mintime'] = mintime if maxtime is not None: params['maxtime'] = maxtime response = self.json_api_call( 'GET', '/admin/v1/info/telephony_credits_used', params ) return response def get_authentication_attempts(self, mintime=None, maxtime=None): """ Returns counts of authentication attempts, broken down by result. mintime - Limit report to data for events after this UNIX timestamp. Defaults to thirty days ago. maxtime - Limit report to data for events before this UNIX timestamp. Defaults to the current time. Returns: { "ERROR": , "FAILURE": , "FRAUD": , "SUCCESS": } where each integer is the number of authentication attempts with that result. Raises RuntimeError on error. """ params = {} if mintime is not None: params['mintime'] = mintime if maxtime is not None: params['maxtime'] = maxtime response = self.json_api_call( 'GET', '/admin/v1/info/authentication_attempts', params ) return response def get_user_authentication_attempts(self, mintime=None, maxtime=None): """ Returns number of unique users with each possible authentication result. mintime - Limit report to data for events after this UNIX timestamp. Defaults to thirty days ago. maxtime - Limit report to data for events before this UNIX timestamp. Defaults to the current time. Returns: { "ERROR": , "FAILURE": , "FRAUD": , "SUCCESS": } where each integer is the number of users who had at least one authentication attempt ending with that result. (These counts are thus always less than or equal to those returned by get_authentication_attempts.) Raises RuntimeError on error. """ params = {} if mintime is not None: params['mintime'] = mintime if maxtime is not None: params['maxtime'] = maxtime response = self.json_api_call( 'GET', '/admin/v1/info/user_authentication_attempts', params ) return response def get_groups_generator(self): """ Returns a generator yielding groups. """ return self.json_paging_api_call( 'GET', '/admin/v1/groups', {} ) def get_groups_by_group_ids(self, group_ids): """ Get a list of groups by their group ids Args: group_ids: list of group ids to fetch Returns: list of groups """ group_id_list = json.dumps(group_ids) return self.json_api_call( 'GET', '/admin/v1/groups', {'group_id_list': group_id_list} ) def get_groups(self, limit=None, offset=0): """ Retrieves a list of groups. Args: limit: The max number of groups to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of groups Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/groups', {'limit': limit, 'offset': offset} ) return list(self.get_groups_generator()) def get_group(self, group_id, api_version=1): """ Returns a group by the group id. group_id - The id of group (Required) api_version - The api version of the handler to use. Currently, the default api version is v1, but the v1 api will be deprecated in a future version of the Duo Admin API. Please migrate to the v2 api at your earliest convenience. For details on the differences between v1 and v2, please see Duo's Admin API documentation. (Optional) """ if api_version == 1: url = '/admin/v1/groups/' warnings.warn( 'The v1 Admin API for group details will be deprecated ' 'in a future release of the Duo Admin API. Please migrate to ' 'the v2 API.', DeprecationWarning) elif api_version == 2: url = '/admin/v2/groups/' else: raise ValueError('Invalid API Version') return self.json_api_call('GET', url + group_id, {}) def get_group_users(self, group_id, limit=None, offset=0): """ Get a paginated list of users associated with the specified group. group_id - The id of the group (Required) limit - The maximum number of records to return. Maximum is 500. (Optional) offset - The offset of the first record to return. (Optional) """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v2/groups/' + group_id + '/users', { 'limit': limit, 'offset': offset, }) return list(self.get_group_users_iterator(group_id)) def get_group_users_iterator(self, group_id): """ Returns an iterator of users associated with the specified group. group_id - The id of the group (Required) """ return self.json_paging_api_call( 'GET', '/admin/v2/groups/' + group_id + '/users', {} ) def create_group(self, name, desc=None, status=None, push_enabled=None, sms_enabled=None, voice_enabled=None, mobile_otp_enabled=None, u2f_enabled=None, ): """ Create a new group. name - The name of the group (Required) desc - Group description (Optional) status - Group authentication status (Optional) push_enabled - Push factor restriction (Optional) sms_enabled - SMS factor restriction (Optional) voice_enabled - Voice factor restriction (Optional) mobile_otp_enabled - Mobile OTP restriction <>True/False (Optional) """ params = { 'name': name, } if desc is not None: params['desc'] = desc if status is not None: params['status'] = status if push_enabled is not None: params['push_enabled'] = '1' if push_enabled else '0' if sms_enabled is not None: params['sms_enabled'] = '1' if sms_enabled else '0' if voice_enabled is not None: params['voice_enabled'] = '1' if voice_enabled else '0' if mobile_otp_enabled is not None: params['mobile_otp_enabled'] = '1' if mobile_otp_enabled else '0' if u2f_enabled is not None: params['u2f_enabled'] = '1' if u2f_enabled else '0' response = self.json_api_call( 'POST', '/admin/v1/groups', params ) return response def delete_group(self, group_id): """ Delete a group by group_id group_id - The id of the group (Required) """ return self.json_api_call( 'DELETE', '/admin/v1/groups/' + group_id, {} ) def modify_group(self, group_id, name=None, desc=None, status=None, push_enabled=None, sms_enabled=None, voice_enabled=None, mobile_otp_enabled=None, u2f_enabled=None, ): """ Modify a group group_id - The id of the group to modify (Required) name - New group name (Optional) desc - New group description (Optional) status - Group authentication status (Optional) push_enabled - Push factor restriction (Optional) sms_enabled - SMS factor restriction (Optional) voice_enabled - Voice factor restriction (Optional) mobile_otp_enabled - Mobile OTP restriction (Optional) u2f_enabled - u2f restriction (Optional) """ params = {} if name is not None: params['name'] = name if desc is not None: params['desc'] = desc if status is not None: params['status'] = status if push_enabled is not None: params['push_enabled'] = '1' if push_enabled else '0' if sms_enabled is not None: params['sms_enabled'] = '1' if sms_enabled else '0' if voice_enabled is not None: params['voice_enabled'] = '1' if voice_enabled else '0' if mobile_otp_enabled is not None: params['mobile_otp_enabled'] = '1' if mobile_otp_enabled else '0' if u2f_enabled is not None: params['u2f_enabled'] = '1' if u2f_enabled else '0' response = self.json_api_call( 'POST', '/admin/v1/groups/' + group_id, params ) return response def get_integrations_generator(self): """ Returns a generator yielding integrations. """ return self.json_paging_api_call( 'GET', '/admin/v3/integrations', {}, ) def get_integrations(self, limit=None, offset=0): """ Retrieves a list of integrations. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of integrations Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v3/integrations', {'limit': limit, 'offset': offset}, ) return list(self.get_integrations_generator()) def get_integration(self, integration_key): """ Returns the requested integration. integration_key - The ikey of the integration to get Returns list of integration objects. Raises RuntimeError on error. """ params = {} response = self.json_api_call( 'GET', '/admin/v3/integrations/' + integration_key, params, ) return response def create_integration(self, name, integration_type, visual_style=None, greeting=None, notes=None, enroll_policy=None, username_normalization_policy=None, adminapi_admins=None, adminapi_info=None, adminapi_integrations=None, adminapi_read_log=None, adminapi_read_resource=None, adminapi_settings=None, adminapi_write_resource=None, trusted_device_days=None, ip_whitelist=None, ip_whitelist_enroll_policy=None, groups_allowed=None, self_service_allowed=None, sso=None, user_access=None): """Creates a new integration. name - The name of the integration (required) integration_type - (required) See adminapi docs for possible values. visual_style - Deprecated; ignored if specified. greeting - (optional, default '') notes - (optional, uses default setting) enroll_policy - (optional, default 'enroll') username_normalization_policy - (optional, default 'none') trusted_device_days - |None ip_whitelist - |None See adminapi docs for more details. ip_whitelist_enroll_policy - See adminapi docs for more details. adminapi_admins - |None adminapi_info - |None adminapi_integrations - |None adminapi_read_log - |None adminapi_read_resource - |None adminapi_settings - |None adminapi_write_resource - |None groups_allowed - self_service_allowed - |None sso - (optional) New argument for unreleased feature. Will return an error if used. Client will be updated again in the future when feature is released. Returns the created integration. Raises RuntimeError on error. """ params = {} if name is not None: params['name'] = name if integration_type is not None: params['type'] = integration_type if visual_style is not None: params['visual_style'] = visual_style if greeting is not None: params['greeting'] = greeting if notes is not None: params['notes'] = notes if username_normalization_policy is not None: params['username_normalization_policy'] = username_normalization_policy if enroll_policy is not None: params['enroll_policy'] = enroll_policy if trusted_device_days is not None: params['trusted_device_days'] = str(trusted_device_days) if ip_whitelist is not None: params['ip_whitelist'] = self._canonicalize_ip_whitelist(ip_whitelist) if ip_whitelist_enroll_policy is not None: params['ip_whitelist_enroll_policy'] = ip_whitelist_enroll_policy if adminapi_admins is not None: params['adminapi_admins'] = '1' if adminapi_admins else '0' if adminapi_info is not None: params['adminapi_info'] = '1' if adminapi_info else '0' if adminapi_integrations is not None: params['adminapi_integrations'] = '1' if adminapi_integrations else '0' if adminapi_read_log is not None: params['adminapi_read_log'] = '1' if adminapi_read_log else '0' if adminapi_read_resource is not None: params['adminapi_read_resource'] = ( '1' if adminapi_read_resource else '0') if adminapi_settings is not None: params['adminapi_settings'] = '1' if adminapi_settings else '0' if adminapi_write_resource is not None: params['adminapi_write_resource'] = ( '1' if adminapi_write_resource else '0') if groups_allowed is not None: params['groups_allowed'] = groups_allowed if self_service_allowed is not None: params['self_service_allowed'] = '1' if self_service_allowed else '0' if sso is not None: params['sso'] = sso if user_access is not None: params['user_access'] = user_access response = self.json_api_call('POST', '/admin/v3/integrations', params, ) return response def get_registered_devices_generator(self): """ Returns a generator yielding Duo Desktop registered devices. """ return self.json_paging_api_call('GET', '/admin/v1/registered_devices', {}) def get_registered_devices(self, limit=None, offset=0): """ Retrieves a list of Duo Desktop registered devices. Args: limit: The max number of registered devices to fetch at once. [Default: None] offset: If a 'limit' is passed, the offset to start retrieval. [Default: 0] Returns: list of registered devices Raises: RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call('GET', '/admin/v1/registered_devices', {'limit': limit, 'offset': offset}) return list(self.get_registered_devices_generator()) def get_registered_device_by_id(self, registered_device_id): """ Returns a Duo Desktop registered device specified by registered_device_id (compkey). Args: registered_device_id - Duo Desktop registered device compkey Returns: registered device object. Raises: RuntimeError on error. """ path = '/admin/v1/registered_devices/' + registered_device_id response = self.json_api_call('GET', path, {}) return response def delete_registered_device(self, registered_device_id): """ Deletes a Duo Desktop registered device. If the registered device has already been deleted, does nothing. Args: registered_device_id - Duo Desktop registered device ID (compkey). Returns: None Raises: RuntimeError on error. """ path = '/admin/v1/registered_devices/' + urllib.parse.quote_plus(registered_device_id) params = {} return self.json_api_call('DELETE', path, params) def get_secret_key(self, integration_key): """Returns the secret key of the specified integration. integration_key - The ikey of the secret key to get. Returns the skey Raises RuntimeError on error. """ params = {} response = self.json_api_call( 'GET', '/admin/v1/integrations/' + integration_key + '/skey', params, ) return response def delete_integration(self, integration_key): """Deletes an integration. integration_key - The integration key of the integration to delete. Raises RuntimeError on error. """ integration_key = urllib.parse.quote_plus(str(integration_key)) path = '/admin/v3/integrations/%s' % integration_key return self.json_api_call( 'DELETE', path, {}, ) def update_integration(self, integration_key, name=None, visual_style=None, greeting=None, notes=None, enroll_policy=None, username_normalization_policy=None, adminapi_admins=None, adminapi_info=None, adminapi_integrations=None, adminapi_read_log=None, adminapi_read_resource=None, adminapi_settings=None, adminapi_write_resource=None, reset_secret_key=None, trusted_device_days=None, ip_whitelist=None, ip_whitelist_enroll_policy=None, groups_allowed=None, self_service_allowed=None, sso=None, user_access=None ): """Updates an integration. integration_key - The key of the integration to update. (required) name - The name of the integration (optional) visual_style - Deprecated; ignored if specified. greeting - Voice greeting (optional, default '') notes - internal use (optional, uses default setting) enroll_policy - <'enroll'|'allow'|'deny'> (optional, default 'enroll') trusted_device_days - |None ip_whitelist - |None See adminapi docs for more details. ip_whitelist_enroll_policy - See adminapi docs for more details. adminapi_admins - |None adminapi_info - True|False|None adminapi_integrations - True|False|None adminapi_read_log - True|False|None adminapi_read_resource - True|False|None adminapi_settings - True|False|None adminapi_write_resource - True|False|None reset_secret_key - |None groups_allowed - self_service_allowed - True|False|None sso - (optional) New argument for unreleased feature. Will return an error if used. Client will be updated again in the future when feature is released. If any value other than None is provided for 'reset_secret_key' (for example, 1), then a new secret key will be generated for the integration. Returns the created integration. Raises RuntimeError on error. """ integration_key = urllib.parse.quote_plus(str(integration_key)) path = '/admin/v3/integrations/%s' % integration_key params = {} if name is not None: params['name'] = name if visual_style is not None: params['visual_style'] = visual_style if greeting is not None: params['greeting'] = greeting if notes is not None: params['notes'] = notes if enroll_policy is not None: params['enroll_policy'] = enroll_policy if username_normalization_policy is not None: params['username_normalization_policy'] = username_normalization_policy if trusted_device_days is not None: params['trusted_device_days'] = str(trusted_device_days) if ip_whitelist is not None: params['ip_whitelist'] = self._canonicalize_ip_whitelist(ip_whitelist) if ip_whitelist_enroll_policy is not None: params['ip_whitelist_enroll_policy'] = ip_whitelist_enroll_policy if adminapi_admins is not None: params['adminapi_admins'] = '1' if adminapi_admins else '0' if adminapi_info is not None: params['adminapi_info'] = '1' if adminapi_info else '0' if adminapi_integrations is not None: params['adminapi_integrations'] = '1' if adminapi_integrations else '0' if adminapi_read_log is not None: params['adminapi_read_log'] = '1' if adminapi_read_log else '0' if adminapi_read_resource is not None: params['adminapi_read_resource'] = ( '1' if adminapi_read_resource else '0') if adminapi_settings is not None: params['adminapi_settings'] = '1' if adminapi_settings else '0' if adminapi_write_resource is not None: params['adminapi_write_resource'] = ( '1' if adminapi_write_resource else '0') if reset_secret_key is not None: params['reset_secret_key'] = '1' if groups_allowed is not None: params['groups_allowed'] = groups_allowed if self_service_allowed is not None: params['self_service_allowed'] = '1' if self_service_allowed else '0' if sso is not None: params['sso'] = sso if user_access is not None: params['user_access'] = user_access if not params: raise TypeError("No new values were provided") response = self.json_api_call( 'POST', path, params, ) return response def get_admins(self, limit=None, offset=0): """ Retrieves a list of administrators. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of administrators. See the adminapi docs. Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/admins', {'limit': limit, 'offset': offset} ) iterator = self.get_admins_iterator() return list(iterator) def get_admins_iterator(self): """ Provides a generator which produces admins. Under the hood, this generator uses pagination, so it will only store one page of admins at a time in memory. Returns: A generator which produces admins. Raises RuntimeError on error. """ return self.json_paging_api_call('GET', '/admin/v1/admins', {}) def get_admin(self, admin_id): """ Returns an administrator. admin_id - The id of the administrator. Returns an administrator. See the adminapi docs. Raises RuntimeError on error. """ admin_id = urllib.parse.quote_plus(str(admin_id)) path = '/admin/v1/admins/%s' % admin_id response = self.json_api_call('GET', path, {}) return response def add_admin(self, name, email, phone, password, role=None): """ Create an administrator and adds it to a customer. name - email - phone - password - Deprecated; ignored if specified. role - Returns the added administrator. See the adminapi docs. Raises RuntimeError on error. """ params = {} if name is not None: params['name'] = name if email is not None: params['email'] = email if phone is not None: params['phone'] = phone if role is not None: params['role'] = role response = self.json_api_call('POST', '/admin/v1/admins', params) return response def update_admin(self, admin_id, name=None, phone=None, password=None, password_change_required=None, status=None, ): """ Update one or more attributes of an administrator. admin_id - The id of the administrator. name - (optional) phone - (optional) password - Deprecated; ignored if specified. password_change_required - (optional) status - the status of the administrator (optional) - NOTE: Valid values are "Active" and "Disabled" - "Disabled" NOT valid for administrators with role - Owner Returns the updated administrator. See the adminapi docs. Raises RuntimeError on error. """ admin_id = urllib.parse.quote_plus(str(admin_id)) path = '/admin/v1/admins/%s' % admin_id params = {} if name is not None: params['name'] = name if phone is not None: params['phone'] = phone if password_change_required is not None: params['password_change_required'] = password_change_required if status is not None: params['status'] = status response = self.json_api_call('POST', path, params) return response def delete_admin(self, admin_id): """ Deletes an administrator. admin_id - The id of the administrator. Raises RuntimeError on error. """ admin_id = urllib.parse.quote_plus(str(admin_id)) path = '/admin/v1/admins/%s' % admin_id return self.json_api_call('DELETE', path, {}) def reset_admin(self, admin_id): """ Resets the admin lockout. admin_id - Raises RuntimeError on error. """ admin_id = urllib.parse.quote_plus(str(admin_id)) path = '/admin/v1/admins/%s/reset' % admin_id return self.json_api_call('POST', path, {}) def activate_admin(self, email, send_email=False, valid_days=None, admin_role=None): """ Generates an activation code for an administrator and optionally emails the administrator. email - valid_days - (optional) send_email - (optional) admin_role - (optional) Returns { "admin_activation_id": "admin_role": "code": "email": "email_sent": "expires": "link": "message": "subject": "valid_days": } See the adminapi docs for updated return values. Raises RuntimeError on error. """ params = {} if email is not None: params['email'] = email if send_email is not None: params['send_email'] = str(send_email) if valid_days is not None: params['valid_days'] = str(valid_days) if admin_role is not None: params['admin_role'] = str(admin_role) response = self.json_api_call('POST', '/admin/v1/admins/activations', params) return response def get_external_password_mgmt_statuses(self, limit=None, offset=0): """ Returns a paged list of administrators indicating whether they have been configured for external password management. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns a list of administrators' external password management statuses. See the adminapi docs. Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/admins/password_mgmt', {'limit': limit, 'offset': offset} ) iterator = self.json_paging_api_call( 'GET', '/admin/v1/admins/password_mgmt', {}) return list(iterator) def get_external_password_mgmt_status_for_admin(self, admin_id): """ Returns the external password management status for an admin admin_id - The id of the admin. Returns an external password management status. See the adminapi docs. Raises RuntimeError on error. """ admin_id = urllib.parse.quote_plus(str(admin_id)) path = '/admin/v1/admins/{}/password_mgmt'.format(admin_id) response = self.json_api_call('GET', path, {}) return response def update_admin_password_mgmt_status( self, admin_id, has_external_password_mgmt=None, password=None): """ Enable or disable an admin for external password management, and optionally set the password for an admin admin_id - The id of the admin. has_external_password_mgmt - whether or not this admin's password is managed via API. password - New password for the admin. Returns an external password management status. See the adminapi docs. Raises RuntimeError on error. """ params = {} if password is not None: params['password'] = password if has_external_password_mgmt is not None: params['has_external_password_mgmt'] = str(has_external_password_mgmt) admin_id = urllib.parse.quote_plus(str(admin_id)) path = '/admin/v1/admins/{}/password_mgmt'.format(admin_id) response = self.json_api_call('POST', path, params) return response def get_logo(self): """ Returns current logo's PNG data or raises an error if none is set. Raises RuntimeError on error. """ response, data = self.api_call('GET', '/admin/v1/logo', params={}) content_type = response.getheader('Content-Type') if content_type and content_type.startswith('image/'): return data else: return self.parse_json_response(response, data) def update_logo(self, logo): """ Set a logo that will appear in future Duo Mobile activations. logo - Raises RuntimeError on error. """ params = { 'logo': base64.b64encode(logo).decode(), } return self.json_api_call('POST', '/admin/v1/logo', params) def delete_logo(self): return self.json_api_call('DELETE', '/admin/v1/logo', params={}) def get_u2ftokens(self, limit=None, offset=0): """ Retrieves a list of u2ftokens Args: limit: The max number of u2ftokens to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: A list of u2ftokens Notes: Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call('GET', '/admin/v1/u2ftokens', {'limit': limit, 'offset': offset}) iterator = self.get_u2ftokens_iterator() return list(iterator) def get_u2ftokens_iterator(self): """ Provides a generator which u2ftokens. Under the hood, this generator uses pagination, so it will only store one page of administrative_units at a time in memory. Returns: A generator which produces u2ftokens. Raises RuntimeError on error. """ return self.json_paging_api_call('GET', '/admin/v1/u2ftokens', {}) def get_webauthncredentials(self, limit=None, offset=0): """ Retrieves a list of webauthn credentials Args: limit: The max number of webauthn credentials to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: A list of webauthn credentials Notes: Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call('GET', '/admin/v1/webauthncredentials', {'limit': limit, 'offset': offset}) iterator = self.get_webauthncredentials_iterator() return list(iterator) def get_webauthncredentials_iterator(self): """ Provides a generator which webauthn credentials. Under the hood, this generator uses pagination, so it will only store one page of administrative_units at a time in memory. Returns: A generator which produces webauthn credentials. Raises RuntimeError on error. """ return self.json_paging_api_call('GET', '/admin/v1/webauthncredentials', {}) def get_u2ftoken_by_id(self, registration_id): """ Returns u2ftoken specified by registration_id. Params: registration_id (str): The registration id of the u2ftoken to fetch. Returns: A u2ftoken dict. Notes: Raises RuntimeError on error. """ registration_id = urllib.parse.quote_plus(str(registration_id)) path = '/admin/v1/u2ftokens/' + registration_id response = self.json_api_call('GET', path, {}) return response def delete_u2ftoken(self, registration_id): """ Deletes a u2ftoken. If the u2ftoken is already deleted, does nothing. Params: registration_id (str): The registration id of the u2f token. Notes: Raises RuntimeError on error. """ registration_id = \ urllib.parse.quote_plus(str(registration_id)) path = '/admin/v1/u2ftokens/' + registration_id return self.json_api_call('DELETE', path, {}) def get_webauthncredential_by_id(self, webauthnkey): """ Returns webauthn credentials specified by webauthnkey. Params: webauthnkey (str): The registration id of the webauthn credentials to fetch. Returns: Returns a webauthn credentials dict. Notes: Raises RuntimeError on error. """ webauthnkey = \ urllib.parse.quote_plus(str(webauthnkey)) path = '/admin/v1/webauthncredentials/' + webauthnkey response = self.json_api_call('GET', path, {}) return response def delete_webauthncredential(self, webauthnkey): """ Deletes a webauthn credentials. If the webauthn credentials is already deleted, does nothing. Params: webauthnkey (str): The registration id of the webauthn credentials. Notes: Raises RuntimeError on error. """ webauthnkey = \ urllib.parse.quote_plus(str(webauthnkey)) path = '/admin/v1/webauthncredentials/' + webauthnkey response = self.json_api_call('DELETE', path, {}) return response def get_bypass_codes_generator(self): """ Returns a generator yielding bypass codes. """ return self.json_paging_api_call( 'GET', '/admin/v1/bypass_codes', {} ) def get_bypass_codes(self, limit=None, offset=0): """ Retrieves a list of bypass codes. Args: limit: The max number of admins to fetch at once. Default None offset: If a limit is passed, the offset to start retrieval. Default 0 Returns: list of bypass codes Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( 'GET', '/admin/v1/bypass_codes', {'limit': limit, 'offset': offset} ) return list(self.get_bypass_codes_generator()) def delete_bypass_code_by_id(self, bypass_code_id): """ Deletes a bypass code. If the bypass code is already deleted, does nothing. Params: bypass_code_id (str): The id of the bypass code. Notes: Raises RuntimeError on error. """ bypass_code_id = \ urllib.parse.quote_plus(str(bypass_code_id)) path = '/admin/v1/bypass_codes/' + bypass_code_id response = self.json_api_call('DELETE', path, {}) return response def sync_user(self, username, directory_key): """ Syncronize a single user immediately with a specified directory. Params: username (str) - The username of the user to be synchronized. directory_key (str) - The unique id of the directory. Notes: Raises RuntimeError on error. """ params = { 'username': username, } directory_key = urllib.parse.quote_plus(directory_key) path = ( '/admin/v1/users/directorysync/{directory_key}/syncuser').format( directory_key=directory_key) return self.json_api_call('POST', path, params) def send_verification_push(self, user_id, phone_id): return self.json_api_call( 'POST', f'/admin/v1/users/{user_id}/send_verification_push', {'phone_id': phone_id} ) def get_verification_push_response(self, user_id, push_id): return self.json_api_call( 'GET', f'/admin/v1/users/{user_id}/verification_push_response', {'push_id': push_id}, ) def get_trust_monitor_events_iterator( self, mintime, maxtime, event_type=None, ): """ Returns a generator which yields trust monitor events. Params: mintime (int) - Return events that have a surfaced timestamp greater than or equal to mintime. Timestamp is represented as a unix timestamp in milliseconds. maxtime (int) - Return events that have a surfaced timestamp less than or equal to maxtime. Timestamp is represented as a unix timestamp in milliseconds. event_type (str, optional) - Limit the events returned by a supplied event type represented as a string. If not supplied, the caller will recieve all event types. Check the Duo Admin API documentation for expected values for this parameter. Returns: Generator which yields trust monitor events. """ params = { "mintime": "{}".format(mintime), "maxtime": "{}".format(maxtime), } if event_type is not None: params["type"] = event_type return self.json_cursor_api_call( "GET", "/admin/v1/trust_monitor/events", params, lambda resp: resp["events"], ) def get_trust_monitor_events_by_offset( self, mintime, maxtime, limit=None, offset=None, event_type=None, ): """ Fetch Duo Trust Monitor Events from the Admin API. Params: mintime (int) - Return events that have a surfaced timestamp greater than or equal to mintime. Timestamp is represented as a unix timestamp in milliseconds. maxtime (int) - Return events that have a surfaced timestamp less than or equal to maxtime. Timestamp is represented as a unix timestamp in milliseconds. limit (int, optional) - Limit the number of events returned. offset (str, optional) - Provide an offset from a previous request's response metadata["next_offset"]. event_type (str, optional) - Limit the events returned by a supplied event type represented as a string. If not supplied, the caller will recieve all event types. Check the Duo Admin API documentation for expected values for this parameter. Returns: response containing a list of Duo Trust Monitor Events. """ params = { "mintime": "{}".format(mintime), "maxtime": "{}".format(maxtime), } if limit is not None: params["limit"] = "{}".format(limit) if offset is not None: params["offset"] = "{}".format(offset) if event_type is not None: params["type"] = event_type return self.json_api_call( "GET", "/admin/v1/trust_monitor/events", params, ) def _quote_policy_id(self, policy_key): return urllib.parse.quote_plus("{}".format(policy_key)) def get_policies_v2_iterator(self): """ Obtain an iterator for retrieving all the policies. The order isn't defined. Returns: Iterator of dict elements. Each element contains the policy content. """ return self.json_paging_api_call( "GET", "/admin/v2/policies", {}, ) def get_policies_v2(self, limit=None, offset=0): """ Retrieves a list of policies. The order isn't defined. Args: limit (int, optional): The max number of policies to fetch at once. offset (int, optional): If a limit is passed, the offset to start retrieval. Default 0 Raises RuntimeError on error. """ (limit, offset) = self.normalize_paging_args(limit, offset) if limit: return self.json_api_call( "GET", "/admin/v2/policies", {"limit": limit, "offset": offset}, ) return list(self.get_policies_v2_iterator()) def delete_policy_v2(self, policy_key): """ Deletes a policy. Params: policy_key (str) - Unique id of the policy Notes: Raises RuntimeError on error. """ path = "/admin/v2/policies/" + self._quote_policy_id(policy_key) return self.json_api_call("DELETE", path, {}) def update_policy_v2(self, policy_key, json_request): """ Update the content of a single policy Args: policy_key (str) - Unique id of the policy json_request (dict) - policy content to update. Returns (dict) - policy after updates have been made. """ path = "/admin/v2/policies/" + self._quote_policy_id(policy_key) response = self.json_api_call("PUT", path, json_request) return response def update_policies_v2(self, sections, sections_to_delete, edit_list, edit_all_policies=False): """ Update the contents of multiple policies. Args: sections (dict): policy content to update sections_to_delete (list): List of section names to delete edit_list (list): List of new policy keys to apply the changes to. Ignored if edit_all_policies is True. edit_all_policies (bool, optional): Apply changes to all policies. Defaults to False. Returns (list): all updated policies """ path = "/admin/v2/policies/update" params = { "policies_to_update": { "edit_all_policies": edit_all_policies, "edit_list": edit_list, }, "policy_changes": { "sections": sections, "sections_to_delete": sections_to_delete, }, } response = self.json_api_call("PUT", path, params) return response def create_policy_v2(self, json_request): """ Args: json_request (dict) - policy content to create. Returns (dict) - newly created policy """ path = "/admin/v2/policies" response = self.json_api_call("POST", path, json_request) return response def copy_policy_v2(self, policy_key, new_policy_names_list): """ Copy policy to multiple new policies. Args: policy_key (str): Unique id of the policy to copy from new_policy_names_list (array): The policy specified by policy_key will be copied once for each name in the list Returns (list): all new policies """ path = "/admin/v2/policies/copy" params = { "policy_key": policy_key, "new_policy_names_list": new_policy_names_list } response = self.json_api_call("POST", path, params) return response def get_policy_v2(self, policy_key): """ Args: policy_key: policy_key (str) - Unique id of the policy Returns (dict) - policy content """ path = "/admin/v2/policies/" + self._quote_policy_id(policy_key) response = self.json_api_call("GET", path, {}) return response def get_policy_summary_v2(self): """ Returns (dict) - summary of all policies and the applications and groups to which they are applied. """ path = "/admin/v2/policies/summary" response = self.json_api_call("GET", path, {}) return response def calculate_policy(self, integration_key, user_id): """ Args: integration_key - The integration_key of the application to evaluate. (required) user_id - The user_id of the user to evaluate (required) Returns (dict) - Dictionary containing "policy_elements" and "sections" """ path = "/admin/v2/policies/calculate" response = self.json_api_call( "GET", path, {"integration_key": integration_key, "user_id": user_id}, ) return response def get_passport_config(self): """ Retrieve the current Passport configuration. Returns (dict): { "enabled_status": string, "enabled_groups": [ { "group_id": user group ID, "group_name": descriptive user group name, ... }, ... ] "disabled_groups": [ { "group_id": user group ID, "group_name": descriptive user group name, ... }, ... ] } """ path = "/admin/v2/passport/config" response = self.json_api_call("GET", path, {}) return response def update_passport_config(self, enabled_status, enabled_groups=[], disabled_groups=[]): """ Update the current Passport configuration. Args: enabled_status (str) - one of "disabled", "enabled", "enabled-for-groups", or "enabled-with-exceptions" enabled_groups (list[str]) - if enabled_status is "enabled-for-groups", a list of user group IDs for whom Passport should be enabled disabled_groups (list[str]) - if enabled_status is "enabled-with-exceptions", a list of user group IDs for whom Passport should be disabled """ path = "/admin/v2/passport/config" response = self.json_api_call( "POST", path, { "enabled_status": enabled_status, "enabled_groups": enabled_groups, "disabled_groups": disabled_groups, }, ) return response class AccountAdmin(Admin): """AccountAdmin manages a child account using an Accounts API integration.""" def __init__(self, account_id, child_api_host=None, **kwargs): """Initializes an AccountAdmin for administering a child account. account_id is the account id of the child account. child_api_host is the api hostname of the child account. If this is not provided, this value will be calculated for correct API usage. See the Client base class for other parameters. """ if not child_api_host: child_api_host = Accounts.child_map.get(account_id, None) if child_api_host is None: child_api_host = kwargs.get('host') try: child_api_host = self.get_child_api_host(account_id, **kwargs) except RuntimeError: pass kwargs['host'] = child_api_host super(AccountAdmin, self).__init__(**kwargs) self.account_id = account_id def get_child_api_host(self, account_id, **kwargs): accounts_api = Accounts(**kwargs) accounts_api.get_child_accounts() return Accounts.child_map.get(account_id, kwargs['host']) def get_edition(self): """ Returns the edition of the account { "edition" : } Raises RuntimeError on error. """ response = self.json_api_call('GET', '/admin/v1/billing/edition', params={}) return response def set_edition(self, edition): """ Set the edition of the child account. edition - The edition string to set on the child account. Should be 'ENTERPRISE' (MFA), 'PLATFORM' (Access), or 'BEYOND'. Raises RuntimeError on error. """ params = { 'edition': edition, } return self.json_api_call('POST', '/admin/v1/billing/edition', params) def get_telephony_credits(self): """ Returns the telephony credits of the account { "credits" : } Raises RuntimeError on error. """ return self.json_api_call('GET', '/admin/v1/billing/telephony_credits', params={}) def set_telephony_credits(self, credits): """ Set the telephony credits of the child account. credits - The telephony credits to set on the child account. The ammount added to the child account, (credits - child's current telephony credits), will be deducted from the parent account's telephony credits. Raises RuntimeError on error. """ params = { 'credits': str(credits), } return self.json_api_call('POST', '/admin/v1/billing/telephony_credits', params) duo_client_python-5.4.0/duo_client/auth.py000066400000000000000000000155211475537715400207340ustar00rootroot00000000000000""" Duo Security Auth API reference client implementation. """ from . import client class Auth(client.Client): def ping(self): """ Determine if the Duo service is up and responding. Returns information about the Duo service state: { 'time': , } """ return self.json_api_call('GET', '/auth/v2/ping', {}) def check(self): """ Determine if the integration key, secret key, and signature generation are valid. Returns information about the Duo service state: { 'time': , } """ return self.json_api_call('GET', '/auth/v2/check', {}) def logo(self): """ Retrieve the user-supplied logo. Returns the logo on success, raises RuntimeError on failure. """ response, data = self.api_call('GET', '/auth/v2/logo', {}) content_type = response.getheader('Content-Type') if content_type and content_type.startswith('image/'): return data else: return self.parse_json_response(response, data) def enroll(self, username=None, valid_secs=None, bypass_codes=None): """ Create a new user and associated numberless phone. Returns activation information: { 'activation_barcode': , 'activation_code': , 'bypass_codes': , 'user_id': , 'username': , 'valid_secs': , } """ params = {} if username is not None: params['username'] = username if valid_secs is not None: valid_secs = str(int(valid_secs)) params['valid_secs'] = valid_secs if bypass_codes is not None: bypass_codes = str(int(bypass_codes)) params['bypass_codes'] = bypass_codes return self.json_api_call('POST', '/auth/v2/enroll', params) def enroll_status(self, user_id, activation_code): """ Check if a user has been enrolled yet. Returns a string constant indicating whether the user has been enrolled or the code remains unclaimed. """ params = { 'user_id': user_id, 'activation_code': activation_code, } response = self.json_api_call('POST', '/auth/v2/enroll_status', params) return response def preauth(self, username=None, user_id=None, ipaddr=None, client_supports_verified_push=None, trusted_device_token=None): """ Determine if and with what factors a user may authenticate or enroll. See the adminapi docs for parameter and response information. """ params = {} if username is not None: params['username'] = username if user_id is not None: params['user_id'] = user_id if ipaddr is not None: params['ipaddr'] = ipaddr if client_supports_verified_push is not None: params['client_supports_verified_push'] = client_supports_verified_push if trusted_device_token is not None: params['trusted_device_token'] = trusted_device_token response = self.json_api_call('POST', '/auth/v2/preauth', params) return response def auth(self, factor, username=None, user_id=None, ipaddr=None, async_txn=False, type=None, display_username=None, pushinfo=None, device=None, passcode=None, txid=None): """ Perform second-factor authentication for a user. If async_txn is True, returns: { 'txid': , } Otherwise, returns: { 'result': , 'status': , 'status_msg': , } If Trusted Devices is enabled, async_txn is not True, and status is 'allow', another item is returned: * trusted_device_token: """ params = { 'factor': factor, 'async': str(int(async_txn)), } if username is not None: params['username'] = username if user_id is not None: params['user_id'] = user_id if ipaddr is not None: params['ipaddr'] = ipaddr if type is not None: params['type'] = type if display_username is not None: params['display_username'] = display_username if pushinfo is not None: params['pushinfo'] = pushinfo if device is not None: params['device'] = device if passcode is not None: params['passcode'] = passcode if txid is not None: params['txid'] = txid response = self.json_api_call('POST', '/auth/v2/auth', params) return response def auth_status(self, txid): """ Longpoll for the status of an asynchronous authentication call. Returns a dict with four items: * waiting: True if the authentication attempt is still in progress and the caller can continue to poll, else False. * success: True if the authentication request has completed and was a success, else False. * status: String constant identifying the request's state. * status_msg: Human-readable string describing the request state. If Trusted Devices is enabled, another item is returned when success is True: * trusted_device_token: String token to bypass second-factor authentication for this user during an admin-defined period. """ params = { 'txid': txid, } status = self.json_api_call('GET', '/auth/v2/auth_status', params) response = { 'waiting': (status.get('result') == 'waiting'), 'success': (status.get('result') == 'allow'), 'status': status.get('status', ''), 'status_msg': status.get('status_msg', ''), } if 'trusted_device_token' in status: response['trusted_device_token'] = status['trusted_device_token'] return response duo_client_python-5.4.0/duo_client/auth_v1.py000066400000000000000000000072711475537715400213450ustar00rootroot00000000000000""" Duo Security Auth API v1 reference client implementation. """ from . import client FACTOR_AUTO = 'auto' FACTOR_PASSCODE = 'passcode' FACTOR_PHONE = 'phone' FACTOR_SMS = 'sms' FACTOR_PUSH = 'push' PHONE1 = 'phone1' PHONE2 = 'phone2' PHONE3 = 'phone3' PHONE4 = 'phone4' PHONE5 = 'phone5' class AuthV1(client.Client): sig_version = 2 auth_details = False def ping(self): """ Returns True if and only if the Duo service is up and responding. """ response = self.json_api_call('GET', '/rest/v1/ping', {}) return response == 'pong' def check(self): """ Returns True if and only if the integration key, secret key, and signature generation are valid. """ response = self.json_api_call('GET', '/rest/v1/check', {}) return response == 'valid' def logo(self): """ Retrieve the user-supplied logo. Returns the logo on success, raises RuntimeError on failure. """ response, data = self.api_call('GET', '/rest/v1/logo', {}) content_type = response.getheader('Content-Type') if content_type and content_type.startswith('image/'): return data else: return self.parse_json_response(response, data) def preauth(self, username, ipaddr=None): params = { 'user': username, } if ipaddr is not None: params['ipaddr'] = ipaddr response = self.json_api_call('POST', '/rest/v1/preauth', params) return response def auth(self, username, factor=FACTOR_PHONE, auto=None, passcode=None, phone=PHONE1, pushinfo=None, ipaddr=None, async_txn=False): """ Returns True if authentication was a success, else False. If 'async_txn' is True, returns txid of the authentication transaction. """ params = { 'user': username, 'factor': factor, } if async_txn: params['async'] = '1' if pushinfo is not None: params['pushinfo'] = pushinfo if ipaddr is not None: params['ipaddr'] = ipaddr if factor == FACTOR_AUTO: params['auto'] = auto elif factor == FACTOR_PASSCODE: params['code'] = passcode elif factor == FACTOR_PHONE: params['phone'] = phone elif factor == FACTOR_SMS: params['phone'] = phone elif factor == FACTOR_PUSH: params['phone'] = phone response = self.json_api_call('POST', '/rest/v1/auth', params) if self.auth_details: return response if async_txn: return response['txid'] return response['result'] == 'allow' def status(self, txid): """ Returns a 3-tuple: (complete, success, description) complete - True if the authentication request has completed, else False. success - True if the authentication request has completed and was a success, else False. description - A string describing the current status of the authentication request. """ params = { 'txid': txid, } response = self.json_api_call('GET', '/rest/v1/status', params) complete = False success = False if 'result' in response: complete = True success = response['result'] == 'allow' description = response['status'] return (complete, success, description) duo_client_python-5.4.0/duo_client/ca_certs.pem000066400000000000000000000331151475537715400217060ustar00rootroot00000000000000subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Assured ID Root CA -----BEGIN CERTIFICATE----- MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+ wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4 VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/ AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe +o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== -----END CERTIFICATE----- subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Global Root CA -----BEGIN CERTIFICATE----- MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= -----END CERTIFICATE----- subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA -----BEGIN CERTIFICATE----- MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm +9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep +OkuE6N36B9K -----END CERTIFICATE----- subject= /C=US/O=SecureTrust Corporation/CN=SecureTrust CA -----BEGIN CERTIFICATE----- MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO 0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj 7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS 8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB /zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3 6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/ 3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR 3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= -----END CERTIFICATE----- subject= /C=US/O=SecureTrust Corporation/CN=Secure Global CA -----BEGIN CERTIFICATE----- MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa /FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7 sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW -----END CERTIFICATE----- subject=C = US, O = Amazon, CN = Amazon Root CA 1 -----BEGIN CERTIFICATE----- MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM 9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L 93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU 5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy rqXRfboQnoZsG4q5WTP468SQvvG5 -----END CERTIFICATE----- subject=C = US, O = Amazon, CN = Amazon Root CA 2 -----BEGIN CERTIFICATE----- MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg 1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K 8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r 2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR 8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz 7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 +XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI 0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY +gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl 7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE 76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H 9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT 4PsJYGw= -----END CERTIFICATE----- subject=C = US, O = Amazon, CN = Amazon Root CA 3 -----BEGIN CERTIFICATE----- MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM YyRIHN8wfdVoOw== -----END CERTIFICATE----- subject=C = US, O = Amazon, CN = Amazon Root CA 4 -----BEGIN CERTIFICATE----- MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi 9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW 1KyLa2tJElMzrdfkviT8tQp21KW8EA== -----END CERTIFICATE----- subject=C = BM, O = QuoVadis Limited, CN = QuoVadis Root CA 2 -----BEGIN CERTIFICATE----- MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0x GTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJv b3QgQ0EgMjAeFw0wNjExMjQxODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNV BAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMRswGQYDVQQDExJRdW9W YWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCa GMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6XJxg Fyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55J WpzmM+Yklvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bB rrcCaoF6qUWD4gXmuVbBlDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp +ARz8un+XJiM9XOva7R+zdRcAitMOeGylZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1 ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt66/3FsvbzSUr5R/7mp/i Ucw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1JdxnwQ5hYIiz PtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og /zOhD7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UH oycR7hYQe7xFSkyyBNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuI yV77zGHcizN300QyNQliBJIWENieJ0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1Ud EwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBQahGK8SEwzJQTU7tD2 A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGUa6FJpEcwRTEL MAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2f BluornFdLwUvZ+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzn g/iN/Ae42l9NLmeyhP3ZRPx3UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2Bl fF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodmVjB3pjd4M1IQWK4/YY7yarHvGH5K WWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK+JDSV6IZUaUtl0Ha B0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrWIozc hLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPR TUIZ3Ph1WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWD mbA4CD/pXvk1B+TJYm5Xf6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0Z ohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y 4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8VCLAAVBpQ570su9t+Oza 8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u -----END CERTIFICATE----- duo_client_python-5.4.0/duo_client/client.py000066400000000000000000000570051475537715400212540ustar00rootroot00000000000000""" Low level functions for generating Duo Web API calls and parsing results. """ __version__ = '5.4.0' import base64 import collections import datetime import email.utils import hashlib import hmac import http.client import json import os import random from time import sleep import socket import ssl import sys import urllib.parse try: # For the optional demonstration CLI program. import argparse except ImportError as e: argparse_error = e argparse = None try: # Only needed if signing requests with timezones other than UTC. import pytz except ImportError as e: pytz = None pytz_error = e from .https_wrapper import CertValidatingHTTPSConnection DEFAULT_CA_CERTS = os.path.join(os.path.dirname(__file__), 'ca_certs.pem') def canon_params(params): """ Return a canonical string version of the given request parameters. """ # this is normalized the same as for OAuth 1.0, # http://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 args = [] for (key, vals) in sorted( (urllib.parse.quote(key, '~'), vals) for (key, vals) in list(params.items())): for val in sorted(urllib.parse.quote(val, '~') for val in vals): args.append('%s=%s' % (key, val)) return '&'.join(args) def canon_x_duo_headers(additional_headers): """ Args: additional_headers: Dict Returns: stringified version of all headers that start with 'X-Duo*'. Which is then hashed. Note: the keys are also lower-cased for signing. """ if additional_headers is None: additional_headers = {} # Lower the headers before sorting them lowered_headers = {} for header_name, header_value in additional_headers.items(): header_name = header_name.lower() if header_name is not None else None lowered_headers[header_name] = header_value canon_list = [] added_headers = [] # store headers we've added, use for duplicate checking (case insensitive) for header_name in sorted(lowered_headers.keys()): # Extract header value and set key to lower case from now on. value = lowered_headers[header_name] # Validation gate. We will raise if a problem is found here. _validate_additional_header(header_name, value, added_headers) # Add to the list of values to canonicalize: canon_list.extend([header_name, value]) added_headers.append(header_name) canon = '\x00'.join(canon_list) return hashlib.sha512(canon.encode('utf-8')).hexdigest() def _validate_additional_header(header_name, value, added_headers): """ Args: header_name: str value: str added_headers: list[str] - headers we've already added - check for duplicates (case insensitive) Returns: None Validates additional headers added to request - headers must comply with the following rules (for V5 sig_version) """ if header_name is None or value is None: raise ValueError("Not allowed 'None' as a header name or value") if '\x00' in header_name: raise ValueError("Not allowed 'Null' character in header name") if '\x00' in value: raise ValueError("Not allowed 'Null' character in header value") if not header_name.lower().startswith('x-duo-'): raise ValueError("Additional headers must start with \'X-Duo-\'") if header_name.lower() in added_headers: raise ValueError("Duplicate header passed, header={}".format(header_name)) def canonicalize(method, host, uri, params, date, sig_version, body=None, additional_headers=None): """ Return a canonical string version of the given request attributes. * method: string HTTP method * host: string hostname * uri: string uri path * params: string containing request params * date: date string for request * sig_version: signature version integer * body: request body, must be string for sig_version 4 """ if sig_version == 1: canon = [ method.upper(), host.lower(), uri, canon_params(params), ] elif sig_version == 2: canon = [ date, method.upper(), host.lower(), uri, canon_params(params), ] elif sig_version == 4: # sig_version 4 is json only canon = [ date, method.upper(), host.lower(), uri, canon_params(params), hashlib.sha512(body.encode('utf-8')).hexdigest(), ] elif sig_version == 5: canon = [ date, method.upper(), host.lower(), uri, canon_params(params), hashlib.sha512(body.encode('utf-8')).hexdigest(), canon_x_duo_headers(additional_headers), # hashed in canon_x_duo_headers ] else: raise ValueError("Unknown signature version: {}".format(sig_version)) return '\n'.join(canon) def sign(ikey, skey, method, host, uri, date, sig_version, params, body=None, digestmod=hashlib.sha512, additional_headers=None): """ Return basic authorization header line with a Duo Web API signature. """ canonical = canonicalize(method, host, uri, params, date, sig_version, body=body, additional_headers=additional_headers) if isinstance(skey, str): skey = skey.encode('utf-8') if isinstance(canonical, str): canonical = canonical.encode('utf-8') sig = hmac.new(skey, canonical, digestmod) auth = '%s:%s' % (ikey, sig.hexdigest()) if isinstance(auth, str): auth = auth.encode('utf-8') b64 = base64.b64encode(auth) if not isinstance(b64, str): b64 = b64.decode('utf-8') return 'Basic %s' % b64 def normalize_params(params): """ Return copy of params with strings listified and unicode strings utf-8 encoded. """ # urllib cannot handle unicode strings properly. quote() excepts, # and urlencode() replaces them with '?'. def encode(value): if isinstance(value, bool): if value: value = 'true' else: value = 'false' elif isinstance(value, int): value = str(value) if isinstance(value, str): return value.encode("utf-8") return value def to_list(value): if value is None or isinstance(value, str): return [value] return value return dict( (encode(key), [encode(v) for v in to_list(value)]) for (key, value) in list(params.items())) class Client(object): sig_version = 5 def __init__(self, ikey, skey, host, ca_certs=DEFAULT_CA_CERTS, sig_timezone='UTC', user_agent=('Duo API Python/' + __version__), timeout=socket._GLOBAL_DEFAULT_TIMEOUT, paging_limit=100, digestmod=hashlib.sha512, sig_version=None, port=None ): """ ca_certs - Path to CA pem file. """ self.ikey = ikey self.skey = skey self.host = host self.port = port self.sig_timezone = sig_timezone if ca_certs is None: ca_certs = DEFAULT_CA_CERTS self.ca_certs = ca_certs self.user_agent = user_agent self.set_proxy(host=None, proxy_type=None) self.paging_limit = paging_limit self.digestmod = digestmod if sig_version is not None: self.sig_version = sig_version # Constants for handling rate limit backoff and retries self._MAX_BACKOFF_WAIT_SECS = 32 self._INITIAL_BACKOFF_WAIT_SECS = 1 self._BACKOFF_FACTOR = 2 self._RATE_LIMITED_RESP_CODE = 429 # Default timeout is a sentinel object if timeout is socket._GLOBAL_DEFAULT_TIMEOUT: self.timeout = timeout else: self.timeout = float(timeout) if sig_version == 3: raise ValueError('sig_version 3 not supported') def set_proxy(self, host, port=None, headers=None, proxy_type='CONNECT'): """ Configure proxy for API calls. Supported proxy_type values: 'CONNECT' - HTTP proxy with CONNECT. None - Disable proxy. """ if proxy_type not in ('CONNECT', None): raise NotImplementedError('proxy_type=%s' % (proxy_type,)) self.proxy_headers = headers self.proxy_host = host self.proxy_port = port self.proxy_type = proxy_type def api_call( self, method, path, params, additional_headers=None, sig_version=None, ): """ Call a Duo API method. Return a (response, data) tuple. * method: HTTP request method. E.g. "GET", "POST", or "DELETE". * path: Full path of the API endpoint. E.g. "/auth/v2/ping". * params: dict mapping from parameter name to stringified value, or a dict to be converted to json. * sig_version: signature version integer """ params_go_in_body = method in ('POST', 'PUT', 'PATCH') digestmod = self.digestmod if additional_headers is None: additional_headers = {} if sig_version is None: sig_version = self.sig_version if sig_version in (1, 2): params = normalize_params(params) # v1 and v2 canonicalization don't distinguish between # params and body. There's no separate body input. body = None elif sig_version in (4, 5): digestmod = hashlib.sha512 if params_go_in_body: body = self.canon_json(params) params = {} else: body = '' params = normalize_params(params) else: raise ValueError('unsupported sig_version {}'.format(sig_version)) if self.sig_timezone == 'UTC': now = email.utils.formatdate() elif pytz is None: raise pytz_error else: d = datetime.datetime.now(pytz.timezone(self.sig_timezone)) now = d.strftime("%a, %d %b %Y %H:%M:%S %z") auth = sign(self.ikey, self.skey, method, self.host, path, now, sig_version, params, body=body, digestmod=digestmod, additional_headers=additional_headers) headers = { 'Authorization': auth, 'Date': now, } if sig_version == 5: for k, v in additional_headers.items(): headers[k] = v if self.user_agent: headers['User-Agent'] = self.user_agent if params_go_in_body: if sig_version in (4, 5): headers['Content-type'] = 'application/json' else: headers['Content-type'] = 'application/x-www-form-urlencoded' body = urllib.parse.urlencode(params, doseq=True) uri = path else: body = None uri = path + '?' + urllib.parse.urlencode(params, doseq=True) encoded_headers = {} for k, v in headers.items(): if isinstance(k, str): k = k.encode('ascii') if isinstance(v, str): v = v.encode('ascii') encoded_headers[k] = v return self._make_request(method, uri, body, encoded_headers) def _connect(self): # Host and port for the HTTP(S) connection to the API server. if self.ca_certs == 'HTTP': api_port = 80 else: api_port = 443 if self.port is not None: api_port = self.port # Host and port for outer HTTP(S) connection if proxied. if self.proxy_type is None: host = self.host port = api_port elif self.proxy_type == 'CONNECT': host = self.proxy_host port = self.proxy_port else: raise NotImplementedError('proxy_type=%s' % (self.proxy_type,)) # Create outer HTTP(S) connection. if self.ca_certs == 'HTTP': conn = http.client.HTTPConnection(host, port) elif self.ca_certs == 'DISABLE': kwargs = {} if hasattr(ssl, '_create_unverified_context'): # httplib.HTTPSConnection validates certificates by # default in Python 2.7.9+. kwargs['context'] = ssl._create_unverified_context() # noqa: DUO122, explicitly disabled for testing scenarios conn = http.client.HTTPSConnection(host, port, **kwargs) else: conn = CertValidatingHTTPSConnection(host, port, ca_certs=self.ca_certs) # Override default socket timeout if requested. conn.timeout = self.timeout # Configure CONNECT proxy tunnel, if any. if self.proxy_type == 'CONNECT': if hasattr(conn, 'set_tunnel'): # 2.7+ conn.set_tunnel(self.host, api_port, self.proxy_headers) elif hasattr(conn, '_set_tunnel'): # 2.6.3+ # pylint: disable=E1103 conn._set_tunnel(self.host, api_port, self.proxy_headers) # pylint: enable=E1103 return conn def _make_request(self, method, uri, body, headers): if self.proxy_type == 'CONNECT': # Ensure the request uses the correct protocol and Host. if self.ca_certs == 'HTTP': api_proto = 'http' else: api_proto = 'https' uri = ''.join((api_proto, '://', self.host, uri)) conn = self._connect() # backoff on rate limited requests and retry. if a request is rate # limited after MAX_BACKOFF_WAIT_SECS, return the rate limited response wait_secs = self._INITIAL_BACKOFF_WAIT_SECS while True: response, data = self._attempt_single_request( conn, method, uri, body, headers) if (response.status != self._RATE_LIMITED_RESP_CODE or wait_secs > self._MAX_BACKOFF_WAIT_SECS): break random_offset = random.uniform(0.0, 1.0) # noqa: DUO102, non-cryptographic random use sleep(wait_secs + random_offset) wait_secs = wait_secs * self._BACKOFF_FACTOR self._disconnect(conn) return (response, data) def _attempt_single_request(self, conn, method, uri, body, headers): conn.request(method, uri, body, headers) response = conn.getresponse() data = response.read() return (response, data) def _disconnect(self, conn): conn.close() def normalize_paging_args(self, limit=None, offset=0): """ Converts paging arguments to a format the rest of the client expects. :param limit: The number of objects requested of a paginated api endpoint. If it looks falsy, it is is not changed. Default None :param offset: The offset to start retrieval. Default 0 :return: tuple after the form of (limit, offset) """ if limit: limit = '{}'.format(limit) offset = '{}'.format(offset) return (limit, offset) def json_api_call(self, method, path, params): """ Call a Duo API method which is expected to return a JSON body with a 200 status. Return the response data structure or raise RuntimeError. """ (response, data) = self.api_call(method, path, params) return self.parse_json_response(response, data) def json_paging_api_call(self, method, path, params): """ Call a Duo API method which is expected to return a JSON body with a 200 status. Return a generator that can be used to get response data or raise a RuntimeError. """ objects = [] next_offset = 0 if 'limit' not in params and self.paging_limit: params['limit'] = str(self.paging_limit) while next_offset is not None: params['offset'] = str(next_offset) (response, data) = self.api_call(method, path, params) (objects, metadata) = self.parse_json_response_and_metadata(response, data) next_offset = metadata.get('next_offset', None) for obj in objects: yield obj def json_cursor_api_call(self, method, path, params, get_records_func): """ Call a Duo API endpoint which utilizes a cursor in some responses to page through a set of data. This cursor is supplied through the optional "offset" parameter. The cursor for the next set of data is in the response metadata as "next_offset". Callers must also include a function parameter to extract the iterable of records to yield. This is slightly different than json_paging_api_call because the first request does not contain the offset parameter. :param method: The method to make the request w/ as a string. Ex: "GET", "POST", "PUT" etc. :param path: The path to make the request with as a string. :param params: The dict of parameters to send in the request. :param get_records_func: Function that can be called to extract an iterable of records from the parsed response json. :returns: Generator which will yield records from the api response(s). """ next_offset = None if 'limit' not in params and self.paging_limit: params['limit'] = str(self.paging_limit) while True: if next_offset is not None: params['offset'] = str(next_offset) (http_resp, http_resp_data) = self.api_call(method, path, params) (response, metadata) = self.parse_json_response_and_metadata( http_resp, http_resp_data, ) for record in get_records_func(response): yield record next_offset = metadata.get('next_offset', None) if next_offset is None: break def parse_json_response(self, response, data): """ Return the parsed data structure or raise RuntimeError. """ (response, metadata) = self.parse_json_response_and_metadata(response, data) return response def parse_json_response_and_metadata(self, response, data): """ Return the parsed data structure and metadata as a tuple or raise RuntimeError. """ def raise_error(msg): error = RuntimeError(msg) error.status = response.status error.reason = response.reason error.data = data raise error if not isinstance(data, str): data = data.decode('utf-8') if response.status != 200: try: data = json.loads(data) if data['stat'] == 'FAIL': if 'message_detail' in data: raise_error('Received %s %s (%s)' % ( response.status, data['message'], data['message_detail'], )) else: raise_error('Received %s %s' % ( response.status, data['message'], )) except (ValueError, KeyError, TypeError): pass raise_error('Received %s %s' % ( response.status, response.reason, )) try: data = json.loads(data) if data['stat'] != 'OK': raise_error('Received error response: %s' % data) response = data['response'] metadata = data.get('metadata', {}) if not metadata and isinstance(response, dict): metadata = response.get('metadata', {}) return (response, metadata) except (ValueError, KeyError, TypeError): raise_error('Received bad response: %s' % data) @classmethod def canon_json(cls, params): if not isinstance(params, dict): raise ValueError('JSON request must be an object.') return json.dumps(params, sort_keys=True, separators=(',', ':')) def output_response(response, data, headers=None): """ Print response, parsed, sorted, and pretty-printed if JSON """ if not headers: headers = [] print(response.status, response.reason) for header in headers: val = response.getheader(header) if val is not None: print('%s: %s' % (header, val)) try: if not isinstance(data, str): data = data.decode('utf-8') data = json.loads(data) data = json.dumps(data, sort_keys=True, indent=4) except ValueError: pass print(data) def main(): if argparse is None: raise argparse_error parser = argparse.ArgumentParser() # named arguments parser.add_argument('--ikey', required=True, help='Duo integration key') parser.add_argument('--skey', required=True, help='Duo integration secret key') parser.add_argument('--host', required=True, help='Duo API hostname') parser.add_argument('--method', required=True, help='HTTP request method') parser.add_argument('--path', required=True, help='API endpoint path') parser.add_argument('--ca', default=DEFAULT_CA_CERTS) parser.add_argument('--sig-version', type=int, default=2) parser.add_argument('--sig-timezone', default='UTC') parser.add_argument( '--show-header', action='append', default=[], metavar='Header-Name', help='Show specified response header(s) (default: only output body).', ) parser.add_argument('--file-args', default=[]) # optional positional arguments are used for GET/POST params, name=val parser.add_argument('param', nargs='*') args = parser.parse_args() client = Client( ikey=args.ikey, skey=args.skey, host=args.host, ca_certs=args.ca, sig_version=args.sig_version, sig_timezone=args.sig_timezone, ) params = collections.defaultdict(list) for p in args.param: try: (k, v) = p.split('=', 1) except ValueError: sys.exit('Error: Positional argument %s is not ' 'in key=value format.' % (p,)) params[k].append(v) # parse which arguments are filenames file_args = args.file_args if args.file_args: file_args = file_args.split(',') for (k, v) in list(params.items()): if k in file_args: # value is a filename, replace with contents if len(v) != 1: # file arguments cannot have multiple values raise NotImplementedError (v,) = v with open(v, 'rb') as val: params[k] = base64.b64encode(val.read()) else: params[k] = v (response, data) = client.api_call(args.method, args.path, params) output_response(response, data, args.show_header) if __name__ == '__main__': main() duo_client_python-5.4.0/duo_client/https_wrapper.py000066400000000000000000000136001475537715400226710ustar00rootroot00000000000000### The following code was adapted from: ### https://googleappengine.googlecode.com/svn-history/r136/trunk/python/google/appengine/tools/https_wrapper.py # Copyright 2007 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # """Extensions to allow HTTPS requests with SSL certificate validation.""" import http.client import re import socket import ssl import urllib.error import urllib.request class InvalidCertificateException(http.client.HTTPException): """Raised when a certificate is provided with an invalid hostname.""" def __init__(self, host, cert, reason): """Constructor. Args: host: The hostname the connection was made to. cert: The SSL certificate (as a dictionary) the host returned. """ http.client.HTTPException.__init__(self) self.host = host self.cert = cert self.reason = reason def __str__(self): return ('Host %s returned an invalid certificate (%s): %s\n' 'To learn more, see ' 'http://code.google.com/appengine/kb/general.html#rpcssl' % (self.host, self.reason, self.cert)) class CertValidatingHTTPSConnection(http.client.HTTPConnection): """An HTTPConnection that connects over SSL and validates certificates.""" default_port = http.client.HTTPS_PORT def __init__(self, host, port=None, key_file=None, cert_file=None, ca_certs=None, strict=None, **kwargs): """Constructor. Args: host: The hostname. Can be in 'host:port' form. port: The port. Defaults to 443. key_file: A file containing the client's private key cert_file: A file containing the client's certificates ca_certs: A file contianing a set of concatenated certificate authority certs for validating the server against. strict: When true, causes BadStatusLine to be raised if the status line can't be parsed as a valid HTTP/1.0 or 1.1 status line. """ http.client.HTTPConnection.__init__(self, host, port, strict, **kwargs) context = ssl.SSLContext(ssl.PROTOCOL_TLS) if cert_file: context.load_cert_chain(cert_file, key_file) if ca_certs: context.verify_mode = ssl.CERT_REQUIRED context.load_verify_locations(cafile=ca_certs) else: context.verify_mode = ssl.CERT_NONE ssl_version_blacklist = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 context.options |= ssl_version_blacklist self.default_ssl_context = context def _GetValidHostsForCert(self, cert): """Returns a list of valid host globs for an SSL certificate. Args: cert: A dictionary representing an SSL certificate. Returns: list: A list of valid host globs. """ if 'subjectAltName' in cert: return [x[1] for x in cert['subjectAltName'] if x[0].lower() == 'dns'] else: return [x[0][1] for x in cert['subject'] if x[0][0].lower() == 'commonname'] def _ValidateCertificateHostname(self, cert, hostname): """Validates that a given hostname is valid for an SSL certificate. Args: cert: A dictionary representing an SSL certificate. hostname: The hostname to test. Returns: bool: Whether or not the hostname is valid for this certificate. """ hosts = self._GetValidHostsForCert(cert) for host in hosts: host_re = host.replace('.', r'\.').replace('*', '[^.]*') if re.search('^%s$' % (host_re,), hostname, re.I): return True return False def connect(self): "Connect to a host on a given (SSL) port." self.sock = socket.create_connection((self.host, self.port), self.timeout) if self._tunnel_host: self._tunnel() self.sock = self.default_ssl_context.wrap_socket(self.sock, server_hostname=self.host) if self.default_ssl_context.verify_mode == ssl.CERT_REQUIRED: cert = self.sock.getpeercert() cert_validation_host = self._tunnel_host or self.host hostname = cert_validation_host.split(':', 0)[0] if not self._ValidateCertificateHostname(cert, hostname): raise InvalidCertificateException(hostname, cert, 'hostname mismatch') class CertValidatingHTTPSHandler(urllib.request.HTTPSHandler): """An HTTPHandler that validates SSL certificates.""" def __init__(self, **kwargs): """Constructor. Any keyword args are passed to the httplib handler.""" super(CertValidatingHTTPSHandler, self).__init__(self) self._connection_args = kwargs def https_open(self, req): def http_class_wrapper(host, **kwargs): full_kwargs = dict(self._connection_args) full_kwargs.update(kwargs) return CertValidatingHTTPSConnection(host, **full_kwargs) try: return self.do_open(http_class_wrapper, req) except urllib.error.URLError as e: if type(e.reason) == ssl.SSLError and e.reason.args[0] == 1: raise InvalidCertificateException(req.host, '', e.reason.args[1]) raise https_request = urllib.request.HTTPSHandler.do_request_ duo_client_python-5.4.0/duo_client/logs/000077500000000000000000000000001475537715400203615ustar00rootroot00000000000000duo_client_python-5.4.0/duo_client/logs/__init__.py000066400000000000000000000001001475537715400224610ustar00rootroot00000000000000from .telephony import Telephony __all__ = [ 'Telephony' ] duo_client_python-5.4.0/duo_client/logs/telephony.py000066400000000000000000000035441475537715400227500ustar00rootroot00000000000000from typing import Callable from duo_client.util import ( get_params_from_kwargs, get_log_uri, get_default_request_times, ) VALID_TELEPHONY_V2_REQUEST_PARAMS = [ "filters", "mintime", "maxtime", "limit", "sort", "next_offset", "account_id", ] LOG_TYPE = "telephony" class Telephony: @staticmethod def get_telephony_logs_v1(json_api_call: Callable, host: str, mintime=0): # Sanity check mintime as unix timestamp, then transform to string mintime = f"{int(mintime)}" params = { "mintime": mintime, } response = json_api_call( "GET", get_log_uri(LOG_TYPE, 1), params, ) for row in response: row["eventtype"] = LOG_TYPE row["host"] = host return response @staticmethod def get_telephony_logs_v2(json_api_call: Callable, host: str, **kwargs): params = {} default_mintime, default_maxtime = get_default_request_times() params = get_params_from_kwargs(VALID_TELEPHONY_V2_REQUEST_PARAMS, **kwargs) if "mintime" not in params: # If mintime is not provided, the script defaults it to 180 days in past params["mintime"] = default_mintime params["mintime"] = f"{int(params['mintime'])}" if "maxtime" not in params: # if maxtime is not provided, the script defaults it to now params["maxtime"] = default_maxtime params["maxtime"] = f"{int(params['maxtime'])}" if "limit" in params: params["limit"] = f"{int(params['limit'])}" response = json_api_call( "GET", get_log_uri(LOG_TYPE, 2), params, ) for row in response["items"]: row["eventtype"] = LOG_TYPE row["host"] = host return response duo_client_python-5.4.0/duo_client/util.py000066400000000000000000000012271475537715400207460ustar00rootroot00000000000000from typing import Dict, Sequence, Tuple from datetime import datetime, timedelta, timezone def get_params_from_kwargs(valid_params: Sequence[str], **kwargs) -> Dict: params = {} for k in kwargs: if kwargs[k] is not None and k in valid_params: params[k] = kwargs[k] return params def get_log_uri(log_type: str, version: int = 1) -> str: return f"/admin/v{version}/logs/{log_type}" def get_default_request_times() -> Tuple[int, int]: today = datetime.now(tz=timezone.utc) mintime = int((today - timedelta(days=180)).timestamp() * 1000) maxtime = int(today.timestamp() * 1000) - 120 return mintime, maxtime duo_client_python-5.4.0/examples/000077500000000000000000000000001475537715400171065ustar00rootroot00000000000000duo_client_python-5.4.0/examples/Accounts/000077500000000000000000000000001475537715400206655ustar00rootroot00000000000000duo_client_python-5.4.0/examples/Accounts/README.md000066400000000000000000000015171475537715400221500ustar00rootroot00000000000000# Duo Accounts API Examples Overview ## Examples This folder contains various examples to illustrate the usage of the `Accounts` module within the `duo_client_python` library. The Duo Accounts API is primarily intended for use by Managed Service Partners (MSP) to assist in the automation of managing their child (customer) Duo accounts. Use of the Duo Accounts API requires special access to be enabled. Please see the [online documentation](https://www.duosecurity.com/docs/accountsapi) for more information. # Using To run an example query, execute a command like the following from the repo root: ```python $ python3 examples/Accounts/get_billing_and_telephony_credits.py ``` Or, from within this folder: ```python $ python3 get_billing_and_telephony_credits.py ``` # Tested Against Python Versions * 3.7 * 3.8 * 3.9 * 3.10 * 3.11 duo_client_python-5.4.0/examples/Accounts/create_child_account.py000066400000000000000000000033671475537715400253720ustar00rootroot00000000000000""" Example of Duo Accounts API child account creation """ import duo_client import sys import getpass from pprint import pprint argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Accounts API ikey, skey and hostname strings """ ikey = _get_next_arg('Duo Accounts API integration key ("DI..."): ') skey = _get_next_arg('Duo Accounts API integration secret key: ', secure=True) host = _get_next_arg('Duo Accounts API hostname ("api-....duosecurity.com"): ') account_name = _get_next_arg('Name for new child account: ') return {"IKEY": ikey, "SKEY": skey, "APIHOST": host, "ACCOUNT_NAME": account_name} def main(): """Main program entry point""" inputs = prompt_for_credentials() account_client = duo_client.Accounts( ikey=inputs['IKEY'], skey=inputs['SKEY'], host=inputs['APIHOST'] ) print(f"Creating child account with name [{inputs['ACCOUNT_NAME']}]") child_account = account_client.create_account(inputs['ACCOUNT_NAME']) if 'account_id' in child_account: print(f"Child account for [{inputs['ACCOUNT_NAME']}] created successfully.") else: print(f"An unexpected error occurred while creating child account for {inputs['ACCOUNT_NAME']}") print(child_account) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Accounts/create_integration_in_child_account.py000066400000000000000000000061441475537715400304570ustar00rootroot00000000000000""" Example of creating an integration in a child account using parent account credentials The key to successfully interacting with child accounts via the parent account APIs is pairing the parent account API IKEY/SKEY combination with the api-host of the child account. Once that connection is established, the child account ID must be passed along with all API interactions. The duo_client SDK makes that easy by allowing the setting of the child account ID as an instance variable. """ import sys import getpass import duo_client # Create an interator to be used by the interactive terminal prompt argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Accounts API ikey, skey and hostname strings """ answers = {'ikey': _get_next_arg('Duo Accounts API integration key ("DI..."): '), 'skey': _get_next_arg('Duo Accounts API integration secret key: ', secure=True), 'host': _get_next_arg('Duo API hostname of child account ("api-....duosecurity.com"): '), 'account_id': _get_next_arg('Child account ID: '), 'app_name': _get_next_arg('New application name: '), 'app_type': _get_next_arg('New application type: ')} return answers def create_child_integration(inputs: dict): """Create new application integration in child account via the parent account API""" # First create a duo_client.Admin instance using the parent account ikey/sky along with the child account api-host account_client = duo_client.Admin(ikey=inputs['ikey'], skey=inputs['skey'], host=inputs['host']) # Next assign the child account ID to the duo_client.Admin instance variable. account_client.account_id = inputs['account_id'] # Now all API calls made via this instance will contain all of the minimum requirements to interact with the # child account. # Here only the two required arguments (name and type) are passed. # Normally, much more information would be provided. The type of additional information # varies by the type of application integration. try: new_app = account_client.create_integration( name=inputs['app_name'], integration_type=inputs['app_type'], ) print(f"New application {inputs['app_name']} (ID: {new_app['integration_key']}) was created successfully.") except RuntimeError as e_str: # Any failure of the API call results in a generic Runtime Error print(f"An error occurred while creating the new application: {e_str}") def main(): """Main program entry point""" inputs = prompt_for_credentials() create_child_integration(inputs) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Accounts/delete_child_account.py000066400000000000000000000040321475537715400253570ustar00rootroot00000000000000""" Example of Duo Accounts API child account deletiom """ import duo_client import sys import getpass from pprint import pprint argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Accounts API ikey, skey and hostname strings """ ikey = _get_next_arg('Duo Accounts API integration key ("DI..."): ') skey = _get_next_arg('Duo Accounts API integration secret key: ', secure=True) host = _get_next_arg('Duo Accounts API hostname ("api-....duosecurity.com"): ') account_id = _get_next_arg('ID of child account to delete: ') return {"IKEY": ikey, "SKEY": skey, "APIHOST": host, "ACCOUNT_ID": account_id} def main(): """Main program entry point""" inputs = prompt_for_credentials() account_client = duo_client.Accounts( ikey=inputs['IKEY'], skey=inputs['SKEY'], host=inputs['APIHOST'] ) account_name = None child_account_list = account_client.get_child_accounts() for account in child_account_list: if account['account_id'] == inputs['ACCOUNT_ID']: account_name = account['name'] if account_name is None: print(f"Unable to find account with ID [{inputs['ACCOUNT_ID']}]") sys.exit() print(f"Deleting child account with name [{account_name}]") deleted_account = account_client.delete_account(inputs['ACCOUNT_ID']) if deleted_account == '': print(f"Account {inputs['ACCOUNT_ID']} was deleted successfully.") else: print(f"An unexpected error occurred while deleting account [{account_name}: {deleted_account}]") if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Accounts/get_account_edition.py000066400000000000000000000032461475537715400252520ustar00rootroot00000000000000""" Example of Duo Accounts API get child account edition """ import duo_client import getpass DUO_EDITIONS = { "ENTERPRISE": "Duo Essentials", "PLATFORM": "Duo Advantage", "BEYOND": "Duo Premier", "PERSONAL": "Duo Free" } def _get_user_input(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts""" ikey = _get_user_input('Duo Accounts API integration key ("DI..."): ') skey = _get_user_input('Duo Accounts API integration secret key: ', secure=True) host = _get_user_input('Duo Accounts API hostname ("api-....duosecurity.com"): ') account_id = _get_user_input('Child account ID: ') return { "ikey": ikey, "skey": skey, "host": host, "account_id": account_id, } def main(): """Main program entry point""" inputs = prompt_for_credentials() account_admin_api = duo_client.admin.AccountAdmin(**inputs) print(f"Getting edition for account ID {inputs['account_id']}...") result = account_admin_api.get_edition() if 'edition' not in result: print(f"An error occurred while getting edition for account {inputs['account_id']}") print(f"Error message: {result}") else: print(f"The current Duo Edition for account {inputs['account_id']} is '{result['edition']}' " + f"[{DUO_EDITIONS[result['edition']]}]") if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Accounts/get_billing_and_telephony_credits.py000066400000000000000000000047371475537715400301570ustar00rootroot00000000000000#!/usr/bin/env python import sys import duo_client EDITIONS = { "ENTERPRISE": "Duo Essentials", "PLATFORM": "Duo Advantage", "BEYOND": "Duo Premier", "PERSONAL": "Duo Free" } def get_next_input(prompt): try: return next(iter(sys.argv[1:])) except StopIteration: return input(prompt) def main(): """Program entry point""" ikey=get_next_input('Accounts API integration key ("DI..."): ') skey=get_next_input('Accounts API integration secret key: ') host=get_next_input('Accounts API hostname ("api-....duosecurity.com"): ') # Configuration and information about objects to create. accounts_api = duo_client.Accounts( ikey=ikey, skey=skey, host=host, ) kwargs = { 'ikey': ikey, 'skey': skey, 'host': host, } # Get all child accounts child_accounts = accounts_api.get_child_accounts() for child_account in child_accounts: # Create AccountAdmin with child account_id, child api_hostname and kwargs consisting of ikey, skey, and host account_admin_api = duo_client.admin.AccountAdmin( child_account['account_id'], child_api_host = child_account['api_hostname'], **kwargs, ) try: # Get edition of child account child_account_edition = account_admin_api.get_edition() print(f"Edition for child account {child_account['name']}: {child_account_edition['edition']}") except RuntimeError as err: # The account might not have access to get billing information if "Received 403 Access forbidden" == str(err): print("{error}: No access for billing feature".format(error=err)) else: print(err) try: # Get telephony credits of child account child_telephony_credits = account_admin_api.get_telephony_credits() print("Telephony credits for child account {name}: {edition}".format( name=child_account['name'], edition=child_telephony_credits['credits']) ) except RuntimeError as err: # The account might not have access to get telephony credits if "Received 403 Access forbidden" == str(err): print("{error}: No access for telephony feature".format(error=err)) else: print(err) if __name__ == "__main__": main() duo_client_python-5.4.0/examples/Accounts/retrieve_account_list.py000066400000000000000000000031631475537715400256360ustar00rootroot00000000000000""" Example of Duo account API uaer accountentication with synchronous request/response """ import duo_client import sys import getpass from pprint import pprint argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Accounts API ikey, skey and hostname strings """ ikey = _get_next_arg('Duo Accounts API integration key ("DI..."): ') skey = _get_next_arg('Duo Accounts API integration secret key: ', secure=True) host = _get_next_arg('Duo Accounts API hostname ("api-....duosecurity.com"): ') return {"IKEY": ikey, "SKEY": skey, "APIHOST": host} def main(): """Main program entry point""" inputs = prompt_for_credentials() account_client = duo_client.Accounts( ikey=inputs['IKEY'], skey=inputs['SKEY'], host=inputs['APIHOST'] ) child_accounts = account_client.get_child_accounts() if isinstance(child_accounts, list): # Expected list of child accounts returned for child_account in child_accounts: print(child_account) if isinstance(child_accounts, dict): # Non-successful response returned print(child_accounts) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Accounts/retrieve_integrations_from_child_account.py000066400000000000000000000036031475537715400315560ustar00rootroot00000000000000""" Example of creating an integration in a child account using parent account credentials """ import argparse import duo_client parser = argparse.ArgumentParser() duo_arg_group = parser.add_argument_group('Duo Accounts API Credentials') duo_arg_group.add_argument('--ikey', help='Duo Accounts API IKEY', required=True ) duo_arg_group.add_argument('--skey', help='Duo Accounts API Secret Key', required=True, ) duo_arg_group.add_argument('--host', help='Duo child account API apihost', required=True ) parser.add_argument('--child_account_id', help='The Duo account ID of the child account to query.', required=True ) args = parser.parse_args() # It is important to note that we are using the IKEY/SKEY combination for an Accounts API integration in the # parent account along with the api-hostname of a child account to create a new duo_client.Admin instance account_client = duo_client.Admin( ikey=args.ikey, skey=args.skey, host=args.host, ) # Once the duo_client.Admin instance is created, the child account_id is assigned. This is necessary to ensure # queries made with this Admin API instance are directed to the proper child account that matches the api-hostname # used to create the instance. account_client.account_id = args.child_account_id def main(): """Main program entry point""" print(f"Retrieving integrations for child account {args.child_account_id}") child_account_integrations = account_client.get_integrations_generator() for integration in child_account_integrations: print(f"{integration['name']=}") if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Accounts/set_account_edition.py000066400000000000000000000040361475537715400252640ustar00rootroot00000000000000""" Example of Duo Accounts API set child account edition """ import duo_client import getpass ALLOWED_DUO_EDITIONS = ("PERSONAL", "ENTERPRISE", "PLATFORM", "BEYOND") def _get_user_input(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts""" ikey = _get_user_input('Duo Accounts API integration key ("DI..."): ') skey = _get_user_input('Duo Accounts API integration secret key: ', secure=True) host = _get_user_input('Duo Accounts API hostname ("api-....duosecurity.com"): ') account_id = _get_user_input('Child account ID: ') account_apihost = _get_user_input('Child account api_hostname: ') account_edition = _get_user_input('Child account edition: ') while account_edition.upper() not in ALLOWED_DUO_EDITIONS: print(f"Invalid account edition. Please select one of {ALLOWED_DUO_EDITIONS}") account_edition = _get_user_input('Child account edition: ') return { "ikey": ikey, "skey": skey, "host": host, "account_id": account_id, "child_api_host": account_apihost, "account_edition": account_edition, } def main(): """Main program entry point""" inputs = prompt_for_credentials() edition = inputs.pop('account_edition') edition = edition.upper() account_admin_api = duo_client.admin.AccountAdmin(**inputs) print(f"Setting edition for account ID {inputs['account_id']} to {edition}") result = account_admin_api.set_edition(edition) if result != "": print(f"An error occurred while setting edition for account {inputs['account_id']}") print(f"Error message: {result}") else: print(f"Edition [{edition}] successfully set for account ID {inputs['account_id']}") if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Admin/000077500000000000000000000000001475537715400201365ustar00rootroot00000000000000duo_client_python-5.4.0/examples/Admin/README.md000066400000000000000000000012771475537715400214240ustar00rootroot00000000000000# Duo Admin API Examples Overview ## Examples This folder contains various examples to illustrate the usage of the `Admin` module within the `duo_client_python` library. The Duo Admin API is primarily intended for automating the management account level elements within a customer configuration such as: - Users - Groups - Phones/Tablets - Tokens - Application integrations - Policies - Logs # Using To run an example query, execute a command like the following from the repo root: ```python $ python3 examples/Admin/report_users_and_phones.py ``` Or, from within this folder: ```python $ python3 report_users_and_phones.py ``` # Tested Against Python Versions * 3.7 * 3.8 * 3.9 * 3.10 * 3.11 duo_client_python-5.4.0/examples/Admin/create_integration_sso_generic.py000066400000000000000000000051061475537715400267400ustar00rootroot00000000000000#!/usr/bin/python import pprint import sys import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): try: return next(argv_iter) except StopIteration: return input(prompt) ikey = get_next_arg('Admin API integration key ("DI..."): ') skey = get_next_arg('integration secret key: ') host = get_next_arg('API hostname ("api-....duosecurity.com"): ') # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey, skey, host, ) integration = admin_api.create_integration( name='api-created integration', integration_type='sso-generic', sso={ "saml_config": { "entity_id": "entity_id", "acs_urls": [ { "url": "https://example.com/acs", "binding": None, "isDefault": None, "index": None, } ], "nameid_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "nameid_attribute": "mail", "sign_assertion": False, "sign_response": True, "signing_algorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "mapped_attrs": {}, "relaystate": "https://example.com/relaystate", "slo_url": "https://example.com/slo", "spinitiated_url": "https://example.com/spurl", "static_attrs": {}, "role_attrs": { "bob": { "ted": ["DGS08MMO53GNRLSFW0D0", "DGETXINZ6CSJO4LRSVKV"], "frank": ["DGETXINZ6CSJO4LRSVKV"], } }, "attribute_transformations": { "attribute_1": 'use ""\nprepend text="dev-"', "attribute_2": 'use ""\nappend additional_attr=""', } } }, ) print('Created integration:') pprint.pprint(integration) duo_client_python-5.4.0/examples/Admin/create_user_and_phone.py000077500000000000000000000033301475537715400250260ustar00rootroot00000000000000#!/usr/bin/python import pprint import sys import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): try: return next(argv_iter) except StopIteration: return input(prompt) # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey=get_next_arg('Admin API integration key ("DI..."): '), skey=get_next_arg('integration secret key: '), host=get_next_arg('API hostname ("api-....duosecurity.com"): '), ) USERNAME = get_next_arg('user login name: ') REALNAME = get_next_arg('user full name: ') # Refer to http://www.duosecurity.com/docs/adminapi for more # information about phone types and platforms. PHONE_NUMBER = get_next_arg('phone number (e.g. +1-555-123-4567): ') PHONE_TYPE = get_next_arg('phone type (e.g. mobile): ') PHONE_PLATFORM = get_next_arg('phone platform (e.g. google android): ') # Create and return a new user object. user = admin_api.add_user( username=USERNAME, realname=REALNAME, ) print('Created user:') pprint.pprint(user) # Create and return a new phone object. phone = admin_api.add_phone( number=PHONE_NUMBER, type=PHONE_TYPE, platform=PHONE_PLATFORM, ) print('Created phone:') pprint.pprint(phone) # Associate the user with the phone. admin_api.add_user_phone( user_id=user['user_id'], phone_id=phone['phone_id'], ) print('Added phone', phone['number'], 'to user', user['username']) # Send two SMS messages to the phone with information about installing # the app for PHONE_PLATFORM and activating it with this Duo account. act_sent = admin_api.send_sms_activation_to_phone( phone_id=phone['phone_id'], install='1', ) print('SMS activation sent to', phone['number'] + ':') pprint.pprint(act_sent) duo_client_python-5.4.0/examples/Admin/get_users_in_group_with_aliases.py000066400000000000000000000073231475537715400271530ustar00rootroot00000000000000""" Example of how to extract users and their aliases for a specific group from the Duo Admin API """ import sys import argparse import dataclasses from collections import deque from duo_client import Admin DUO_MAX_USERS_PER_API_CALL = 100 @dataclasses.dataclass class DuoUser: """ Duo User object class for storage and retrieval of pertinent information per user """ username: str = None user_id: str = None group_name: str = None aliases: str = None def set_group_name(self, group_name): self.group_name = group_name def set_aliases(self, aliases: list): self.aliases = ','.join(aliases) def get_user_info(self): return_string = (f"'username': {self.username}, 'user_id': {self.user_id}, " + f"'group_name': {self.group_name}, 'aliases': '{self.aliases}'") return return_string parser = argparse.ArgumentParser() duo_arg_group = parser.add_argument_group('Duo Admin API Credentials') duo_arg_group.add_argument('--ikey', help='Duo Admin API IKEY', required=True ) duo_arg_group.add_argument('--skey', help='Duo Admin API Secret Key', required=True, ) duo_arg_group.add_argument('--host', help='Duo Admin API apihost', required=True ) parser.add_argument('--group_name', help="Name of group to get users from. Groups are case-sensitive.", required=True ) args = parser.parse_args() duo_admin_client = Admin( ikey=args.ikey, skey=args.skey, host=args.host ) def split_list(input_list: list, size: int) -> list: """Split a list into chunks based on size""" return [input_list[i:i + size] for i in range(0, len(input_list), size)] def get_duo_group_users(group_name: str) -> list: """Get the list of users assigned to the given group name""" group_id = None try: groups = (duo_admin_client.get_groups()) except Exception as e_str: print(f"Exception while retrieving groups: {e_str}") sys.exit(1) for group in groups: if group['name'] == group_name: group_id = group['group_id'] break user_list = list(deque(duo_admin_client.get_group_users_iterator(group_id))) return split_list(user_list, DUO_MAX_USERS_PER_API_CALL) def get_duo_user_aliases(user_list: list[list]) -> list: """Collect aliases for the users in the given list""" all_users = [] for u_list in user_list: users = list(deque(duo_admin_client.get_users_by_ids([uid['user_id'] for uid in u_list]))) for user in users: new_duo_user = DuoUser(user_id=user['user_id'], username=user['username']) new_duo_user.set_aliases(list(user['aliases'].values())) all_users.append(new_duo_user) return all_users def output_user_aliases(user_list: list[DuoUser], group_name: str) -> None: """Output the list of users and their aliases for the requested group""" for user in user_list: user.set_group_name(group_name) print(user.get_user_info()) def main(): """Main program entry point""" if args.group_name is not None: duo_group_users = get_duo_group_users(args.group_name) if len(duo_group_users) == 0: print(f"Unable to find users assigned to group named '{args.group_name}'.") sys.exit(1) duo_group_user_aliases = get_duo_user_aliases(duo_group_users) output_user_aliases(duo_group_user_aliases, args.group_name) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Admin/log_examples.py000066400000000000000000000067731475537715400232040ustar00rootroot00000000000000#!/usr/bin/env python import csv import sys from datetime import datetime, timedelta, timezone import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt, default=None): try: return next(argv_iter) except StopIteration: return input(prompt) or default today = datetime.now(tz=timezone.utc) default_mintime = int((today - timedelta(days=180)).timestamp()) default_maxtime = int(today.timestamp() * 1000) - 120 # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey=get_next_arg("Admin API integration key: "), skey=get_next_arg("Integration secret key: "), host=get_next_arg("API hostname: "), ) params = {} mintime = get_next_arg("Mintime: ", default_mintime) if mintime: params["mintime"] = mintime maxtime = get_next_arg("Maxtime: ", default_maxtime) if maxtime: params["maxtime"] = maxtime limit = get_next_arg("Limit (1000): ") if limit: params["limit"] = limit next_offset = get_next_arg("Next_offset: ") if next_offset: params["next_offset"] = next_offset sort = get_next_arg("Sort (ts:desc): ") if sort: params["sort"] = sort log_type = get_next_arg("Log Type (telephony_v2): ", "telephony_v2") print(f"Fetching {log_type} logs...") reporter = csv.writer(sys.stdout) print("==============================") if log_type == "activity": params["mintime"] = params["mintime"] * 1000 activity_logs = admin_api.get_activity_logs(**params) print( "Next offset from response: ", activity_logs.get("metadata").get("next_offset") ) reporter.writerow( ("activity_id", "ts", "action", "actor_name", "target_name", "application") ) for log in activity_logs["items"]: activity = log["activity_id"] ts = log["ts"] action = log["action"] actor_name = log.get("actor", {}).get("name", None) target_name = log.get("target", {}).get("name", None) application = log.get("application", {}).get("name", None) reporter.writerow( [ activity, ts, action, actor_name, target_name, application, ] ) if log_type == "telephony_v2": telephony_logs = admin_api.get_telephony_log(api_version=2, kwargs=params) reporter.writerow(("telephony_id", "txid", "credits", "context", "phone", "type")) for log in telephony_logs["items"]: telephony_id = log["telephony_id"] txid = log["txid"] credits = log["credits"] context = log["context"] phone = log["phone"] type = log["type"] reporter.writerow( [ telephony_id, txid, credits, context, phone, type ] ) if log_type == "auth": auth_logs = admin_api.get_authentication_log(api_version=2, kwargs=params) print( "Next offset from response: ", auth_logs.get("metadata").get("next_offset"), ) reporter.writerow(("admin", "akey", "context", "phone", "provider")) for log in auth_logs["authlogs"]: admin = log["admin_name"] akey = log["akey"] context = log["context"] phone = log["phone"] provider = log["provider"] reporter.writerow( [ admin, akey, context, phone, provider, ] ) print("==============================") duo_client_python-5.4.0/examples/Admin/policies.py000077500000000000000000000121011475537715400223150ustar00rootroot00000000000000#!/usr/bin/env python import sys import json import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): try: return next(argv_iter) except StopIteration: return input(prompt) admin_api = duo_client.Admin( ikey=get_next_arg('Admin API integration key ("DI..."): '), skey=get_next_arg("integration secret key: "), host=get_next_arg('API hostname ("api-....duosecurity.com"): '), ) def create_empty_policy(name, print_response=False): """ Create an empty policy with a specified name. """ json_request = { "policy_name": name, } response = admin_api.create_policy_v2(json_request) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) return response.get("policy_key") def create_policy_browsers(name, print_response=False): """ Create a policy that blocks internet explorer browsers. Requires Access or Beyond editions. """ json_request = { "policy_name": name, "sections": { "browsers": { "blocked_browsers_list": [ "ie", ], }, }, } response = admin_api.create_policy_v2(json_request) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) return response.get("policy_key") def copy_policy(name1, name2, copy_from, print_response=False): """ Copy the policy `copy_from` to two new policies. """ response = admin_api.copy_policy_v2(copy_from, [name1, name2]) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) policies = response.get("policies") return (policies[0].get("policy_key"), policies[1].get("policy_key")) def bulk_delete_section(policy_keys, print_response=False): """ Delete the section "browsers" from the provided policies. """ response = admin_api.update_policies_v2("", ["browsers"], policy_keys) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) def update_policy_with_device_health_app(policy_key, print_response=False): """ Update a given policy to include Duo Device Health App policy settings. Requires Access or Beyond editions. NOTE: this function is deprecated, please use update_policy_with_duo_desktop """ return update_policy_with_duo_desktop(policy_key, print_response) def update_policy_with_duo_desktop(policy_key, print_response=False): """ Update a given policy to include Duo Desktop policy settings. Requires Access or Beyond editions. """ json_request = { "sections": { "duo_desktop": { "enforce_encryption": ["windows"], "enforce_firewall": ["windows"], "requires_duo_desktop": ["windows"], "windows_endpoint_security_list": ["cisco-amp"], "windows_remediation_note": "Please install Windows agent", }, }, } response = admin_api.update_policy_v2(policy_key, json_request) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) return response.get("policy_key") def get_policy(policy_key): """ Fetch a given policy. """ response = admin_api.get_policy_v2(policy_key) pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) def iterate_all_policies(): """ Loop over each policy. """ print("#####################") print("Iterating over all policies...") print("#####################") iter = sorted( admin_api.get_policies_v2_iterator(), key=lambda x: x.get("policy_name") ) for policy in iter: print( "##################### {} {}".format( policy.get("policy_name"), policy.get("policy_key") ) ) pretty = json.dumps(policy, indent=4, sort_keys=True, default=str) print(pretty) def main(): # Create two empty policies policy_key_a = create_empty_policy("Test New Policy - a") policy_key_b = create_empty_policy("Test New Policy - b") # Update policy with Duo Desktop settings. update_policy_with_duo_desktop(policy_key_b) # Create an empty policy and delete it. policy_key_c = create_empty_policy("Test New Policy - c") admin_api.delete_policy_v2(policy_key_c) # Create a policy with browser restriction settings. policy_key_d = create_policy_browsers("Test New Policy - d") # Copy a policy to 2 new policies. policy_key_e, policy_key_f = copy_policy("Test New Policy - e", "Test New Policy - f", policy_key_d) # Delete the browser restriction settings from 2 policies. bulk_delete_section([policy_key_e, policy_key_f]) # Fetch the global and other custom policy. get_policy("global") get_policy(policy_key_b) # Loop over each policy. iterate_all_policies() if __name__ == "__main__": main() duo_client_python-5.4.0/examples/Admin/policies_advanced.py000077500000000000000000000131631475537715400241530ustar00rootroot00000000000000""" Example of Duo Admin API policies operations """ import json import duo_client from getpass import getpass class DuoPolicy(): """Base class for Duo Policy object properties and methods""" def __init__(self): """Initialize Duo Policy""" ... def get_next_user_input(prompt: str, secure: bool = False) -> str: """Collect input from user via standard input device""" return getpass(prompt) if secure is True else input(prompt) admin_api = duo_client.Admin( ikey=get_next_user_input('Admin API integration key ("DI..."): '), skey=get_next_user_input("Admin API integration secret key: ", secure=True), host=get_next_user_input('API hostname ("api-....duosecurity.com"): '), ) def create_empty_policy(name, print_response=False): """ Create an empty policy with a specified name. """ json_request = { "policy_name": name, } response = admin_api.create_policy_v2(json_request) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) return response.get("policy_key") def create_policy_browsers(name, print_response=False): """ Create a policy that blocks internet explorer browsers. Requires Access or Beyond editions. """ json_request = { "policy_name": name, "sections": { "browsers": { "blocked_browsers_list": [ "ie", ], }, }, } response = admin_api.create_policy_v2(json_request) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) return response.get("policy_key") def copy_policy(name1, name2, copy_from, print_response=False): """ Copy the policy `copy_from` to two new policies. """ response = admin_api.copy_policy_v2(copy_from, [name1, name2]) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) policies = response.get("policies") return (policies[0].get("policy_key"), policies[1].get("policy_key")) def bulk_delete_section(policy_keys, print_response=False): """ Delete the section "browsers" from the provided policies. """ response = admin_api.update_policies_v2("", ["browsers"], policy_keys) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) def update_policy_with_device_health_app(policy_key, print_response=False): """ Update a given policy to include Duo Device Health App policy settings. Requires Access or Beyond editions. NOTE: this function is deprecated, please use update_policy_with_duo_desktop """ return update_policy_with_duo_desktop(policy_key, print_response) def update_policy_with_duo_desktop(policy_key, print_response=False): """ Update a given policy to include Duo Desktop policy settings. Requires Access or Beyond editions. """ json_request = { "sections": { "duo_desktop": { "enforce_encryption": ["windows"], "enforce_firewall": ["windows"], "requires_duo_desktop": ["windows"], "windows_endpoint_security_list": ["cisco-amp"], "windows_remediation_note": "Please install Windows agent", }, }, } response = admin_api.update_policy_v2(policy_key, json_request) if print_response: pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) return response.get("policy_key") def get_policy(policy_key): """ Fetch a given policy. """ response = admin_api.get_policy_v2(policy_key) pretty = json.dumps(response, indent=4, sort_keys=True, default=str) print(pretty) def iterate_all_policies(): """ Loop over each policy. """ print("#####################") print("Iterating over all policies...") print("#####################") iter = sorted( admin_api.get_policies_v2_iterator(), key=lambda x: x.get("policy_name") ) for policy in iter: print( "##################### {} {}".format( policy.get("policy_name"), policy.get("policy_key") ) ) pretty = json.dumps(policy, indent=4, sort_keys=True, default=str) print(pretty) def main(): """Primary program entry point""" # Create two empty policies policy_key_a = create_empty_policy("Test New Policy - a") policy_key_b = create_empty_policy("Test New Policy - b") # Update policy with Duo Desktop settings. update_policy_with_duo_desktop(policy_key_b) # Create an empty policy and delete it. policy_key_c = create_empty_policy("Test New Policy - c") admin_api.delete_policy_v2(policy_key_c) # Create a policy with browser restriction settings. policy_key_d = create_policy_browsers("Test New Policy - d") # Copy a policy to 2 new policies. policy_key_e, policy_key_f = copy_policy("Test New Policy - e", "Test New Policy - f", policy_key_d) # Delete the browser restriction settings from 2 policies. bulk_delete_section([policy_key_e, policy_key_f]) # Fetch the global and other custom policy. get_policy("global") get_policy(policy_key_b) # Loop over each policy. iterate_all_policies() if __name__ == "__main__": main() duo_client_python-5.4.0/examples/Admin/report_auths_by_country.py000077500000000000000000000020431475537715400255060ustar00rootroot00000000000000#!/usr/bin/env python import csv import sys import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): try: return next(argv_iter) except StopIteration: return input(prompt) # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey=get_next_arg('Admin API integration key ("DI..."): '), skey=get_next_arg('integration secret key: '), host=get_next_arg('API hostname ("api-....duosecurity.com"): '), ) # Retrieve log info from API: logs = admin_api.get_authentication_log() # Count authentications by country: counts = dict() for log in logs: country = log['location']['country'] if country != '': counts[country] = counts.get(country, 0) + 1 # Print CSV of country, auth count: auths_descending = sorted(counts.items(), reverse=True) reporter = csv.writer(sys.stdout) print("[+] Report of auth counts by country:") reporter.writerow(('Country', 'Auth Count')) for row in auths_descending: reporter.writerow([ row[0], row[1], ]) duo_client_python-5.4.0/examples/Admin/report_user_activity.py000077500000000000000000000022741475537715400250050ustar00rootroot00000000000000#!/usr/bin/env python import sys from datetime import datetime, timezone import duo_client argv_iter = iter(sys.argv[1:]) def get_next_input(prompt): """Collect user input from terminal and return it.""" try: return next(argv_iter) except StopIteration: return input(prompt) def human_time(time: int) -> str: """Translate unix time into human readable string""" if time is None: date_str = 'Never' else: date_str = datetime.fromtimestamp(time, timezone.utc).strftime("%Y-%m-%m %H:%M:%S") return date_str # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey=get_next_input('Admin API integration key ("DI..."): '), skey=get_next_input('integration secret key: '), host=get_next_input('API hostname ("api-....duosecurity.com"): '), ) # Retrieve user info from API: users = admin_api.get_users() print(f'{"Username":^30} {"Last Login":^20} {"User Enrolled"}') print(f'{"=" * 30} {"=" * 20} {"=" * 15}') for user in users: line_out = f"{user['username']:30} " line_out += f"{human_time(user['last_login']):20} " line_out += f" {user['is_enrolled']} " print(line_out) duo_client_python-5.4.0/examples/Admin/report_user_by_email.py000077500000000000000000000023301475537715400247230ustar00rootroot00000000000000#!/usr/bin/env python """ Script to illustrate how to retrieve a user from the Duo Admin API using the associated email address""" import sys import getpass import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def main(): """ Primary script execution code """ # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey=get_next_arg('Admin API integration key ("DI..."): '), skey=get_next_arg('integration secret key: ', secure=True), host=get_next_arg('API hostname ("api-....duosecurity.com"): '), ) # Retrieve user info from API: email_address = get_next_arg('E-mail address of user to retrieve: ') user = admin_api.get_user_by_email(email_address) if user: print(user) else: print(f"User with email [{email_address}] could not be found.") if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Admin/report_users_and_phones.py000077500000000000000000000021121475537715400254410ustar00rootroot00000000000000#!/usr/bin/env python import csv import sys import duo_client argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): try: return next(argv_iter) except StopIteration: return input(prompt) # Configuration and information about objects to create. admin_api = duo_client.Admin( ikey=get_next_arg('Admin API integration key ("DI..."): '), skey=get_next_arg('integration secret key: '), host=get_next_arg('API hostname ("api-....duosecurity.com"): '), ) # Retrieve user info from API: users = admin_api.get_users() # Print CSV of username, phone number, phone type, and phone platform: # # (If a user has multiple phones, there will be one line printed per # associated phone.) reporter = csv.writer(sys.stdout) print("[+] Report of all users and associated phones:") reporter.writerow(('Username', 'Phone Number', 'Type', 'Platform')) for user in users: for phone in user["phones"]: reporter.writerow([ user["username"], phone["number"], phone["type"], phone["platform"], ]) duo_client_python-5.4.0/examples/Admin/trust_monitor_events.py000077500000000000000000000024061475537715400250310ustar00rootroot00000000000000#!/usr/bin/env python """Print Duo Trust Monitor Events which surfaced within the past two weeks.""" import json import sys from datetime import datetime, timedelta, timezone from duo_client import Admin argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): try: return next(argv_iter) except StopIteration: return input(prompt) def main(args): # Instantiate the Admin client object. admin_client = Admin(args[0], args[1], args[2]) # Query for Duo Trust Monitor events that were surfaced within the last two weeks (from today). now = datetime.now(tz=timezone.utc) mintime_ms = int((now - timedelta(weeks=2)).timestamp() * 1000) maxtime_ms = int(now.timestamp() * 1000) # Loop over the returned iterator to navigate through each event, printing it to stdout. for event in admin_client.get_trust_monitor_events_iterator(mintime_ms, maxtime_ms): print(json.dumps(event, sort_keys=True)) def parse_args(): ikey=get_next_arg('Duo Admin API integration key ("DI..."): ') skey=get_next_arg('Duo Admin API integration secret key: ') host=get_next_arg('Duo Admin API hostname ("api-....duosecurity.com"): ') return (ikey, skey, host,) if __name__ == "__main__": args = parse_args() main(args) duo_client_python-5.4.0/examples/Admin/update_phone_names.py000077500000000000000000000022721475537715400243540ustar00rootroot00000000000000""" Script to pull list of all phones and modify the name of each """ import sys import getpass import duo_client argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) admin_api = duo_client.Admin( ikey=_get_next_arg('Admin API integration key ("DI..."): '), skey=_get_next_arg('integration secret key: ', secure=True), host=_get_next_arg('API hostname ("api-....duosecurity.com"): '), ) phones = admin_api.get_phones() for phone in phones: print(f"Current phone name for device ID {phone['phone_id']} is {phone['name']}") new_phone_name = phone['name'] + '_new' print(f"Changing name to {new_phone_name}") result = admin_api.update_phone(phone_id=phone['phone_id'], name=new_phone_name) if result['name'] == new_phone_name: print(f"Device {phone['phone_id']} is now named {new_phone_name}.") else: print("An error occurred.") duo_client_python-5.4.0/examples/Auth/000077500000000000000000000000001475537715400200075ustar00rootroot00000000000000duo_client_python-5.4.0/examples/Auth/README.md000066400000000000000000000017151475537715400212720ustar00rootroot00000000000000# Duo Auth API Examples Overview ## Examples This folder contains various examples to illustrate the usage of the `Auth` module within the `duo_client_python` library. The Duo Auth API is primarily intended for integrating user enrollment and authentication into a custom third-party application. The expectation is that the third-party application is providing the necessary user interface and supporting structure to complete primary authentication for users before calling the Duo Auth API for secure second factor authentication. These examples use console/tty based interactions to collect necessary information to provide fully functional interactions with the Duo Auth API. # Using To run an example query, execute a command like the following from the repo root: ```python $ python3 examples/Auth/basic_user_mfa.py ``` Or, from within this folder: ```python $ python3 basic_user_mfa.py ``` # Tested Against Python Versions * 3.7 * 3.8 * 3.9 * 3.10 * 3.11 duo_client_python-5.4.0/examples/Auth/async_advanced_user_mfa.log000066400000000000000000000173611475537715400253450ustar00rootroot000000000000002023-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 2023-12-20 03:49:21,262 [INFO] async_advanced_user_mfa : __init__(47) - ========== Starting async_advanced_user_mfa.py ========== 2023-12-20 03:49:21,710 [INFO] async_advanced_user_mfa : ping_duo(141) - Duo service check completed successfully. 2023-12-20 03:49:22,155 [INFO] async_advanced_user_mfa : verify_duo(151) - IKEY and SKEY provided have been verified. 2023-12-20 03:49:22,156 [INFO] async_advanced_user_mfa : _cleanup_authentications_dictionary(160) - #### Starting thread Auth-dict-cleanup #### 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : preauth_user_from_queue(193) - #### Starting thread Pre-auth-worker-0 #### 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : auth_user_from_queue(217) - #### Starting thread Auth-worker-0 #### 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : get_user_auth_result(242) - #### Starting thread Result-worker-0 #### 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 2023-12-20 03:49:22,157 [INFO] async_advanced_user_mfa : preauth_user_from_queue(193) - #### Starting thread Pre-auth-worker-1 #### 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : auth_user_from_queue(217) - #### Starting thread Auth-worker-1 #### 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : get_user_auth_result(242) - #### Starting thread Result-worker-1 #### 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : preauth_user_from_queue(193) - #### Starting thread Pre-auth-worker-2 #### 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : auth_user_from_queue(217) - #### Starting thread Auth-worker-2 #### 2023-12-20 03:49:22,158 [INFO] async_advanced_user_mfa : get_user_auth_result(242) - #### Starting thread Result-worker-2 #### 2023-12-20 03:49:22,159 [DEBUG] async_advanced_user_mfa : prompt_for_username(184) - Prompting for username... 2023-12-20 03:49:29,220 [DEBUG] async_advanced_user_mfa : prompt_for_username(186) - Username: mark@duomark.net received 2023-12-20 03:49:29,220 [INFO] async_advanced_user_mfa : prompt_for_username(188) - mark@duomark.net placed in user_queue. 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... 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'} 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... 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... 2023-12-20 03:49:30,225 [DEBUG] async_advanced_user_mfa : prompt_for_username(184) - Prompting for username... 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 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... 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... 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]... 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...'}] 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]... 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...'} 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... 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 2023-12-20 03:50:13,970 [DEBUG] async_advanced_user_mfa : close(113) - Signal number 2 received. 2023-12-20 03:50:13,971 [DEBUG] async_advanced_user_mfa : close(114) - Frame traceback: None 2023-12-20 03:50:13,971 [INFO] async_advanced_user_mfa : close(115) - SIGINIT received. Waiting for threads to complete... 2023-12-20 03:50:13,971 [INFO] async_advanced_user_mfa : close(117) - Setting instance RUNNING property to False... 2023-12-20 03:50:13,971 [INFO] async_advanced_user_mfa : close(121) - Waiting for Auth-dict-cleanup thread to complete... 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... 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... 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... 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... 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... 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... 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... 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Pre-auth-worker-0 thread to complete... 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Auth-worker-0 thread to complete... 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Result-worker-0 thread to complete... 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Pre-auth-worker-2 thread to complete... 2023-12-20 03:50:22,172 [INFO] async_advanced_user_mfa : close(121) - Waiting for Auth-worker-2 thread to complete... 2023-12-20 03:50:22,173 [INFO] async_advanced_user_mfa : close(121) - Waiting for Result-worker-2 thread to complete... 2023-12-20 03:50:22,173 [INFO] async_advanced_user_mfa : close(127) - All threads complete. Shutting down. duo_client_python-5.4.0/examples/Auth/async_advanced_user_mfa.py000066400000000000000000000320661475537715400252130ustar00rootroot00000000000000""" Example of Duo Auth API with asynchronous user authentication action This example uses the threading and queue libraries to illustrate how multiple users could potentially have authentication requests in flight at the same time while the application polls for responses on each authentication event without blocking main program execution. """ import getpass import logging import queue import signal import os import sys import time import threading from pathlib import Path import traceback from logging.handlers import RotatingFileHandler from datetime import datetime from duo_client import Auth FIVE_MINUTES = 5 * 60 WORKER_THREADS = 3 SHUTDOWN_TIMEOUT = 10 WORKER_SLEEP_INTERVAL = 0.5 def _write_auth_entry(auth_entry: dict) -> None: """Write authentication result entry to separate log file""" filename = Path(__file__).with_name("user_authentication_result.log") human_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with open(filename, 'a', encoding='utf-8') as auth_fn: auth_fn.write(f"{human_time} - {auth_entry}\n") class DuoAuthAPI: """ Class to hold global variables and methods used by the Duo Auth """ def __init__(self): """Setup Duo Auth API object""" self.RUNNING = True self.DEBUG = True self.lock = threading.Lock() signal.signal(signal.SIGINT, self.close) self.logger = self._init_logger() self.logger.info(f"========== Starting {Path(__file__).name} ==========") self.stderr_tmp = sys.stderr sys.stderr = open(os.devnull, 'w') credentials = self.prompt_for_credentials() self._auth_client = Auth( ikey=credentials['IKEY'], skey=credentials['SKEY'], host=credentials['APIHOST'] ) if not self.ping_duo(): self.exit_with_error("Duo Ping failed.") if not self.verify_duo(): self.exit_with_error("Unable to verify Duo Auth API credentials.") self.authentications = {} """ self.authentications[txid] = { "timestamp": int, "username": str, "success": bool, "status": str, "message": str """ self.user_queue = queue.Queue() self.auth_queue = queue.Queue() self.result_queue = queue.Queue() self.initialize_threads() @staticmethod def _init_logger(): logger = logging.getLogger(__name__) f = Path(__file__) log_handler = RotatingFileHandler( filename=f.with_name(f.stem + ".log"), maxBytes=25000000, backupCount=5 ) LOGGING_FORMAT = "{asctime} [{levelname}]\t{module} : {funcName}({lineno}) - {message}" log_handler.setFormatter(logging.Formatter(LOGGING_FORMAT, style='{')) logger.addHandler(log_handler) logger.setLevel(logging.DEBUG) logger.info(f"Logger created with file {f.with_name(f.stem + '.log')} at log level " + f"{logging.getLevelName(logger.getEffectiveLevel())}") return logger @staticmethod def _get_user_input(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" if secure is True: return getpass.getpass(prompt) else: return input(prompt) def close(self, signal_number, frame): """ Handle CRTL-C interrupt signal and exit program """ if self.DEBUG is True: self.logger.debug(f"Signal number {signal_number} received.") self.logger.debug(f"Frame traceback: {traceback.print_stack(frame)}") self.logger.info(f"SIGINIT received. Waiting for threads to complete...") print(f"\n\nSIGINIT received. Waiting for threads to complete...\n") self.logger.info("Setting instance RUNNING property to False...") self.RUNNING = False for thread in threading.enumerate(): if thread != threading.main_thread(): self.logger.info(f"Waiting for {thread.name} thread to complete...") print(f"{thread.name} shutting down...") thread.join(timeout=SHUTDOWN_TIMEOUT) if thread.is_alive() is True: self.logger.info(f"{thread.name} did not shut down gracefully.") print(f" {thread.name} did not shut down gracefully.") self.logger.info(f"All threads complete. Shutting down.") sys.stderr = self.stderr_tmp print(f"All threads complete. Shutting down.") sys.exit() def exit_with_error(self, reason: str) -> None: """Log error message and exit program""" self.logger.error(f"Exiting with error: {reason}") sys.exit() def ping_duo(self) -> bool: """Verify that the Duo service is available""" duo_ping = self._auth_client.ping() if 'time' in duo_ping: self.logger.info("Duo service check completed successfully.") return True else: self.logger.error(f"Error: {duo_ping}") return False def verify_duo(self) -> bool: """Verify that IKEY and SKEY information provided are valid""" duo_check = self._auth_client.check() if 'time' in duo_check: self.logger.info("IKEY and SKEY provided have been verified.") return True else: self.logger.error(f"Error: {duo_check}") return False def _cleanup_authentications_dictionary(self): """Background task to remove old data from authentications dictionary""" t_name = threading.current_thread().name self.logger.info(f"#### Starting thread {t_name} ####") while self.RUNNING is True: threshold_time = int(time.time()) - FIVE_MINUTES self.logger.info(f"[{t_name}] Scanning for authentication data for older than {threshold_time}") self.lock.acquire(blocking=True) try: for txid in list(self.authentications.keys()): if self.authentications[txid]['timestamp'] < threshold_time: self.logger.warning(f"[{t_name}] *** Removing {txid} from authentications dictionary ***") del self.authentications[txid] finally: self.lock.release() time.sleep(30) self.logger.info(f"[{t_name}] RUNNING property set to False. Cleaning up...") def prompt_for_credentials(self) -> dict: """Collect required API credentials from command line prompts and return them in a dictionary format""" ikey = self._get_user_input('Duo Auth API integration key ("DI..."): ') skey = self._get_user_input('Duo Auth API integration secret key: ', secure=True) host = self._get_user_input('Duo Auth API hostname ("api-....duosecurity.com"): ') return {"IKEY": ikey, "SKEY": skey, "APIHOST": host} def prompt_for_username(self) -> None: """Collect username from TTY and place on preauth_queue.""" self.logger.debug(f"Prompting for username...") if self.DEBUG is True else ... username = self._get_user_input("Duo username to authenticate: ") self.logger.debug(f" Username: {username} received") if self.DEBUG is True else ... self.user_queue.put_nowait(username) self.logger.info(f" {username} placed in user_queue.") def preauth_user_from_queue(self) -> None: """Preauth user from pre-auth queue""" t_name = threading.current_thread().name self.logger.info(f"#### Starting thread {t_name} ####") duo_user = None got_item = False while self.RUNNING is True and got_item is False: try: duo_user = self.user_queue.get(block=False) got_item = True except queue.Empty: time.sleep(WORKER_SLEEP_INTERVAL) if got_item is False: continue self.logger.info(f"[{t_name}] Executing pre-authentication for {duo_user}...") pre_auth = self._auth_client.preauth(duo_user) self.logger.info(f"[{t_name}] Pre-authentication result for {duo_user} is {pre_auth}") if pre_auth['result'] == 'auth': self.auth_queue.put_nowait((duo_user, pre_auth)) self.user_queue.task_done() else: self.logger.error(f"[{t_name}] Pre-auth for {duo_user} failed. Reason: {pre_auth}") self.logger.info(f"[{t_name}] RUNNING property set to False. Cleaning up...") def auth_user_from_queue(self) -> None: """Authenticate user from pre-auth queue""" t_name = threading.current_thread().name self.logger.info(f"#### Starting thread {t_name} ####") duo_user = None got_item = False while self.RUNNING is True and got_item is False: try: (duo_user, pre_auth_result) = self.auth_queue.get(block=False) got_item = True except queue.Empty: time.sleep(WORKER_SLEEP_INTERVAL) if got_item is False: continue try: self.logger.info(f"[{t_name}] Executing asynchronous authentication action for {duo_user}...") auth = self._auth_client.auth(factor="push", username=duo_user, device="auto", async_txn=True) if 'txid' in auth: self.logger.info(f"[{t_name}] Placing {auth['txid']} in result_queue for user {duo_user}") self.result_queue.put_nowait((duo_user, auth['txid'])) self.auth_queue.task_done() except Exception as e_str: self.logger.exception(f"[{t_name}] Exception caught: {e_str}") self.logger.info(f"[{t_name}] RUNNING property set to False. Cleaning up...") def get_user_auth_result(self) -> None: """Gets user authentication result from result_queue""" t_name = threading.current_thread().name self.logger.info(f"#### Starting thread {t_name} ####") duo_user = None txid = None got_item = False while self.RUNNING is True and got_item is False: try: (duo_user, txid) = self.result_queue.get(block=False) got_item = True except queue.Empty: time.sleep(WORKER_SLEEP_INTERVAL) if got_item is False: continue self.logger.info(f"[{t_name}] Getting authentication result for TXID {txid}, username {duo_user}...") waiting = True while waiting is True: self.logger.info(f"[{t_name}] Waiting for {duo_user} to respond [{txid}]...") auth_status = self._auth_client.auth_status(txid) if auth_status['waiting'] is not True: waiting = False self.logger.info(f"[{t_name}] Authentication result for {duo_user} [{txid}] is {auth_status}") # Record authentication result for potential use elsewhere in the program self.lock.acquire() try: self.authentications[txid] = { "timestamp": int(time.time()), "message": auth_status['status_msg'], "status": auth_status['status'], "success": auth_status['success'], "username": duo_user } finally: self.lock.release() _write_auth_entry(self.authentications[txid]) else: self.logger.info(f"[{t_name}] Still waiting for {duo_user} to respond [{auth_status}]") self.result_queue.task_done() self.logger.info(f"[{t_name}] RUNNING property set to False. Cleaning up...") def initialize_threads(self): """Start background worker threads to monitor queues and process items""" threading.Thread(target=self._cleanup_authentications_dictionary, name="Auth-dict-cleanup", daemon=True).start() for i in range(WORKER_THREADS): threading.Thread(target=self.preauth_user_from_queue, name=f"Pre-auth-worker-{i}", daemon=True).start() threading.Thread(target=self.auth_user_from_queue, name=f"Auth-worker-{i}", daemon=True).start() threading.Thread(target=self.get_user_auth_result, name=f"Result-worker-{i}", daemon=True).start() def run(self): """Run the program setup and loop""" while self.RUNNING is True: self.prompt_for_username() time.sleep(1) self.logger.info(f"[run()] RUNNING property set to False. Cleaning up...") sys.stderr = self.stderr_tmp if __name__ == '__main__': duo_auth_api = DuoAuthAPI() duo_auth_api.run() duo_client_python-5.4.0/examples/Auth/async_basic_user_mfa.py000066400000000000000000000065221475537715400245250ustar00rootroot00000000000000""" Example of Duo Auth API user authentication using asynchronous resquest/response methods """ import duo_client import sys import getpass argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Auth API ikey, skey and hostname strings """ ikey = _get_next_arg('Duo Auth API integration key ("DI..."): ') skey = _get_next_arg('Duo Auth API integration secret key: ', secure=True) host = _get_next_arg('Duo Auth API hostname ("api-....duosecurity.com"): ') username = _get_next_arg('Duo Username: ') return {"USERNAME": username, "IKEY": ikey, "SKEY": skey, "APIHOST": host} def main(): """Main program entry point""" inputs = prompt_for_credentials() auth_client = duo_client.Auth( ikey=inputs['IKEY'], skey=inputs['SKEY'], host=inputs['APIHOST'] ) # Verify that the Duo service is available duo_ping = auth_client.ping() if 'time' in duo_ping: print("\nDuo service check completed successfully.") else: print(f"Error: {duo_ping}") # Verify that IKEY and SKEY information provided are valid duo_check= auth_client.check() if 'time' in duo_check: print("IKEY and SKEY provided have been verified.") else: print(f"Error: {duo_check}") # Execute pre-authentication for given user print(f"\nExecuting pre-authentication for {inputs['USERNAME']}...") pre_auth = auth_client.preauth(username=inputs['USERNAME']) if pre_auth['result'] == "auth": try: print(f"Executing authentication action for {inputs['USERNAME']}...") auth = auth_client.auth(factor="push", username=inputs['USERNAME'], device="auto", async_txn=True) if 'txid' in auth: waiting = True # Collect the authentication result print("Getting authentication result...") # Repeat long polling for async authentication status until no longer in a 'waiting' state while waiting is True: # Poll Duo Auth API for the status of the async authentication based upon transaction ID auth_status = auth_client.auth_status(auth['txid']) print(f"Auth status: {auth_status}") if auth_status['waiting'] is not True: # Waiting for response too async authentication is no longer 'True', so break the loop waiting = False # Parse response for the 'status' dictionary key to determine whether to allow or deny print(auth_status) else: # Some kind of unexpected error occurred print(f"Error: an unknown error occurred attempting authentication for [{inputs['USERNAME']}]") except Exception as e_str: print(e_str) else: print(pre_auth['status_msg']) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Auth/basic_user_mfa.py000066400000000000000000000066131475537715400233310ustar00rootroot00000000000000""" Example of Duo Auth API uaer authentication with synchronous request/response """ import duo_client import sys import getpass from pprint import pprint argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Auth API ikey, skey and hostname strings """ ikey = _get_next_arg('Duo Auth API integration key ("DI..."): ') skey = _get_next_arg('Duo Auth API integration secret key: ', secure=True) host = _get_next_arg('Duo Auth API hostname ("api-....duosecurity.com"): ') username = _get_next_arg('Duo Username: ') return {"USERNAME": username, "IKEY": ikey, "SKEY": skey, "APIHOST": host} def main(): """Main program entry point""" inputs = prompt_for_credentials() auth_client = duo_client.Auth( ikey=inputs['IKEY'], skey=inputs['SKEY'], host=inputs['APIHOST'] ) # Verify that the Duo service is available duo_ping = auth_client.ping() if 'time' in duo_ping: print("\nDuo service check completed successfully.") else: print(f"Error: {duo_ping}") # Verify that IKEY and SKEY information provided are valid duo_check= auth_client.check() if 'time' in duo_check: print("IKEY and SKEY provided have been verified.") else: print(f"Error: {duo_check}") # Execute pre-authentication for given user print(f"\nExecuting pre-authentication for {inputs['USERNAME']}...") pre_auth = auth_client.preauth(username=inputs['USERNAME']) if pre_auth['result'] == "auth": try: # User exists and has an MFA device enrolled print(f"Executing authentication action for {inputs['USERNAME']}...") # "auto" is selected for the factor in this example, however the pre_auth['devices'] dictionary # element contains a list of factors available for the provided user, if an alternate method is desired auth = auth_client.auth(factor="auto", username=inputs['USERNAME'], device="auto") print(f"\n{auth['status_msg']}") except Exception as e_str: print(e_str) elif pre_auth['result'] == "allow": # User is in bypass mode print(pre_auth['status_msg']) elif pre_auth['result'] == "enroll": # User is unknown and not enrolled in Duo with a 'New User' policy setting of 'Require Enrollment' # Setting a 'New User' policy to 'Require Enrollment' should only be done for Group level policies where # the intent is to capture "partially enrolled" users. "Parially enrolled" users are those that Duo has a # defined username but does not have an MFA device enrolled. print("Please enroll in Duo using the following URL.") print(pre_auth['enroll_portal_url']) elif pre_auth['result'] == "deny": # User is denied by policy setting print(pre_auth['status_msg']) else: print("Error: an unexpected error occurred") print(pre_auth) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/Auth/basic_user_mfa_token.py000066400000000000000000000052031475537715400245230ustar00rootroot00000000000000""" Example of Duo Auth API uaer authentication with synchronous request/response using an assigned token as the MFA factor """ import duo_client import sys import getpass from pprint import pprint argv_iter = iter(sys.argv[1:]) def _get_next_arg(prompt, secure=False): """Read information from STDIN, using getpass when sensitive information should not be echoed to tty""" try: return next(argv_iter) except StopIteration: if secure is True: return getpass.getpass(prompt) else: return input(prompt) def prompt_for_credentials() -> dict: """Collect required API credentials from command line prompts :return: dictionary containing Duo Auth API ikey, skey and hostname strings """ ikey = _get_next_arg('Duo Auth API integration key ("DI..."): ') skey = _get_next_arg('Duo Auth API integration secret key: ', secure=True) host = _get_next_arg('Duo Auth API hostname ("api-....duosecurity.com"): ') username = _get_next_arg('Duo Username: ') return {"USERNAME": username, "IKEY": ikey, "SKEY": skey, "APIHOST": host} def main(): """Main program entry point""" inputs = prompt_for_credentials() auth_client = duo_client.Auth( ikey=inputs['IKEY'], skey=inputs['SKEY'], host=inputs['APIHOST'] ) # Verify that the Duo service is available duo_ping = auth_client.ping() if 'time' in duo_ping: print("\nDuo service check completed successfully.") else: print(f"Error: {duo_ping}") # Verify that IKEY and SKEY information provided are valid duo_check= auth_client.check() if 'time' in duo_check: print("IKEY and SKEY provided have been verified.") else: print(f"Error: {duo_check}") # Execute pre-authentication for given user print(f"\nExecuting pre-authentication for {inputs['USERNAME']}...") pre_auth = auth_client.preauth(username=inputs['USERNAME']) print("\n" + "=" * 30) pprint(f"Pre-Auth result: {pre_auth}") print("=" * 30 + "\n") for device in pre_auth['devices']: pprint(device) print() if pre_auth['result'] == "auth": try: print(f"Executing authentication action for {inputs['USERNAME']}...") # Prompt for the hardware token passcode passcode = _get_next_arg('Duo token passcode: ') auth = auth_client.auth(factor="passcode", username=inputs['USERNAME'], passcode=passcode) print(f"\n{auth['status_msg']}") except Exception as e_str: print(e_str) else: print(pre_auth) if __name__ == '__main__': main() duo_client_python-5.4.0/examples/README.md000066400000000000000000000015021475537715400203630ustar00rootroot00000000000000# Duo API Examples Overview ## Examples This folder contains several sub-folders, each containing examples to illustrate the usage of the various Duo Security APIs. ------- ### Admin API The Duo Admin API provides access to endpoints that are primarily focused on Duo account operational tasks, such as: - User management - MFA device management - Integration management - Policy management - Log extractions ------- ### Auth API The Duo Auth API provides access to user enrollment and authentication services and is primarily intended for use by application developers that want to integration Duo MFA functionality into their applications. ------- ### Accounts API The Duo Accounts API provides access to Duo account management functionality and is primarily intended for use by Duo Managed Service Provider (MSP) partners.duo_client_python-5.4.0/examples/splunk/000077500000000000000000000000001475537715400204225ustar00rootroot00000000000000duo_client_python-5.4.0/examples/splunk/duo.conf000066400000000000000000000002301475537715400220530ustar00rootroot00000000000000[duo] ; admin api integration key ikey = ; admin api secret key skey = ; api- host = ; HTTP proxy support ;http_proxy = http://host[:port] duo_client_python-5.4.0/examples/splunk/splunk.py000077500000000000000000000156411475537715400223220ustar00rootroot00000000000000#!/usr/bin/python import os import sys import time import duo_client from configparser import ConfigParser from urllib.parse import urlparse class BaseLog: def __init__(self, admin_api, path, logname): self.admin_api = admin_api self.path = path self.logname = logname self.mintime = 0 self.events = [] def get_events(self): raise NotImplementedError def print_events(self): raise NotImplementedError def get_last_timestamp_path(self): """ Returns the path to the file containing the timestamp of the last event fetched. """ filename = self.logname + "_last_timestamp_" + self.admin_api.host path = os.path.join(self.path, filename) return path def get_mintime(self): """ Updates self.mintime which is the minimum timestamp of log events we want to fetch. self.mintime is > all event timestamps we have already fetched. """ try: # Only fetch events that come after timestamp of last event path = self.get_last_timestamp_path() self.mintime = int(open(path).read().strip()) + 1 except IOError: pass def write_last_timestamp(self): """ Store last_timestamp so that we don't fetch the same events again """ if not self.events: # Do not update last_timestamp return last_timestamp = 0 for event in self.events: last_timestamp = max(last_timestamp, event['timestamp']) path = self.get_last_timestamp_path() f = open(path, "w") f.write(str(last_timestamp)) f.close() def run(self): """ Fetch new log events and print them. """ self.events = [] self.get_mintime() self.get_events() self.print_events() self.write_last_timestamp() class AdministratorLog(BaseLog): def __init__(self, admin_api, path): BaseLog.__init__(self, admin_api, path, "administrator") def get_events(self): self.events = self.admin_api.get_administrator_log( mintime=self.mintime, ) def print_events(self): """ Print events in a format suitable for Splunk. """ for event in self.events: event['ctime'] = time.ctime(event['timestamp']) event['actionlabel'] = { 'admin_login': "Admin Login", 'admin_create': "Create Admin", 'admin_update': "Update Admin", 'admin_delete': "Delete Admin", 'customer_update': "Update Customer", 'group_create': "Create Group", 'group_update': "Update Group", 'group_delete': "Delete Group", 'integration_create': "Create Integration", 'integration_update': "Update Integration", 'integration_delete': "Delete Integration", 'phone_create': "Create Phone", 'phone_update': "Update Phone", 'phone_delete': "Delete Phone", 'user_create': "Create User", 'user_update': "Update User", 'user_delete': "Delete User"}.get( event['action'], event['action']) fmtstr = '%(timestamp)s,' \ 'host="%(host)s", ' \ 'eventtype="%(eventtype)s", ' \ 'username="%(username)s", ' \ 'action="%(actionlabel)s"' if event['object']: fmtstr += ', object="%(object)s"' if event['description']: fmtstr += ', description="%(description)s"' print(fmtstr % event) class AuthenticationLog(BaseLog): def __init__(self, admin_api, path): BaseLog.__init__(self, admin_api, path, "authentication") def get_events(self): self.events = self.admin_api.get_authentication_log( mintime=self.mintime, ) def print_events(self): """ Print events in a format suitable for Splunk. """ for event in self.events: event['ctime'] = time.ctime(event['timestamp']) fmtstr = ( '%(timestamp)s,' 'host="%(host)s", ' 'eventtype="%(eventtype)s", ' 'username="%(username)s", ' 'factor="%(factor)s", ' 'result="%(result)s", ' 'reason="%(reason)s", ' 'ip="%(ip)s", ' 'integration="%(integration)s", ' 'newenrollment="%(new_enrollment)s"' ) print(fmtstr % event) class TelephonyLog(BaseLog): def __init__(self, admin_api, path): BaseLog.__init__(self, admin_api, path, "telephony") def get_events(self): self.events = self.admin_api.get_telephony_log( mintime=self.mintime, ) def print_events(self): """ Print events in a format suitable for Splunk. """ for event in self.events: event['ctime'] = time.ctime(event['timestamp']) event['host'] = self.admin_api.host fmtstr = '%(timestamp)s,' \ 'host="%(host)s", ' \ 'eventtype="%(eventtype)s", ' \ 'context="%(context)s", ' \ 'type="%(type)s", ' \ 'phone="%(phone)s", ' \ 'credits="%(credits)s"' print(fmtstr % event) def admin_api_from_config(config_path): """ Return a duo_client.Admin object created using the parameters stored in a config file. """ config = ConfigParser() config.read(config_path) config_d = dict(config.items('duo')) ca_certs = config_d.get("ca_certs", None) if ca_certs is None: ca_certs = config_d.get("ca", None) ret = duo_client.Admin( ikey=config_d['ikey'], skey=config_d['skey'], host=config_d['host'], ca_certs=ca_certs, ) http_proxy = config_d.get("http_proxy", None) if http_proxy is not None: proxy_parsed = urlparse(http_proxy) proxy_host = proxy_parsed.hostname proxy_port = proxy_parsed.port ret.set_proxy(host = proxy_host, port = proxy_port) return ret def main(): if len(sys.argv) == 1: config_path = os.path.abspath(__file__) config_path = os.path.dirname(config_path) config_path = os.path.join(config_path, "duo.conf") else: config_path = os.path.abspath(sys.argv[1]) admin_api = admin_api_from_config(config_path) # Use the directory of the config file to store the last event tstamps path = os.path.dirname(config_path) for logclass in (AdministratorLog, AuthenticationLog, TelephonyLog): log = logclass(admin_api, path) log.run() if __name__ == '__main__': main() duo_client_python-5.4.0/requirements-dev.txt000066400000000000000000000000521475537715400213250ustar00rootroot00000000000000nose2 flake8 pytz>=2022.1 dlint freezegun duo_client_python-5.4.0/requirements.txt000066400000000000000000000000131475537715400205460ustar00rootroot00000000000000setuptools duo_client_python-5.4.0/setup.cfg000066400000000000000000000000371475537715400171110ustar00rootroot00000000000000[bdist_rpm] release=1%%{?dist} duo_client_python-5.4.0/setup.py000066400000000000000000000024651475537715400170110ustar00rootroot00000000000000from setuptools import setup import os.path import duo_client requirements_filename = os.path.join( os.path.dirname(os.path.abspath(__file__)), "requirements.txt" ) with open(requirements_filename) as fd: install_requires = [i.strip() for i in fd.readlines()] requirements_dev_filename = os.path.join( os.path.dirname(os.path.abspath(__file__)), "requirements-dev.txt" ) with open(requirements_dev_filename) as fd: tests_require = [i.strip() for i in fd.readlines()] long_description_filename = os.path.join( os.path.dirname(os.path.abspath(__file__)), "README.md" ) with open(long_description_filename) as fd: long_description = fd.read() setup( name="duo_client", version=duo_client.__version__, description="Reference client for Duo Security APIs", long_description=long_description, long_description_content_type="text/markdown", author="Duo Security, Inc.", author_email="support@duosecurity.com", url="https://github.com/duosecurity/duo_client_python", packages=["duo_client", "duo_client.logs"], package_data={"duo_client": ["ca_certs.pem"]}, license="BSD", classifiers=[ "Programming Language :: Python", "License :: OSI Approved :: BSD License", ], install_requires=install_requires, tests_require=tests_require, ) duo_client_python-5.4.0/tests/000077500000000000000000000000001475537715400164325ustar00rootroot00000000000000duo_client_python-5.4.0/tests/__init__.py000066400000000000000000000000001475537715400205310ustar00rootroot00000000000000duo_client_python-5.4.0/tests/accountAdmin/000077500000000000000000000000001475537715400210375ustar00rootroot00000000000000duo_client_python-5.4.0/tests/accountAdmin/__init__.py000066400000000000000000000000001475537715400231360ustar00rootroot00000000000000duo_client_python-5.4.0/tests/accountAdmin/base.py000066400000000000000000000013701475537715400223240ustar00rootroot00000000000000import unittest from unittest.mock import patch from .. import util import duo_client.admin class TestAccountAdmin(unittest.TestCase): def setUp(self): child_host = 'example2.com' kwargs = {'ikey': 'test_ikey', 'skey': 'test_skey', 'host': 'example.com'} patcher = patch("duo_client.admin.AccountAdmin.get_child_api_host") self.mock_child_host = patcher.start() self.mock_child_host.return_value = child_host self.addCleanup(patcher.stop) self.client = duo_client.admin.AccountAdmin( 'DA012345678901234567', **kwargs) # monkeypatch client's _connect() self.client._connect = lambda: util.MockHTTPConnection() if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/accountAdmin/test_billing.py000066400000000000000000000052251475537715400240740ustar00rootroot00000000000000import json from .. import util import duo_client.admin from .base import TestAccountAdmin class TestBilling(TestAccountAdmin): def test_get_billing_edition(self): """Test to get billing edition """ response = self.client.get_edition() uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/billing/edition') self.assertEqual(util.params_to_dict(args), { 'account_id': [self.client.account_id], }) def test_set_business_billing_edition(self): """Test to set PLATFORM billing edition """ response = self.client.set_edition('PLATFORM') uri = response['uri'] self.assertEqual(response['method'], 'POST') self.assertEqual(uri, '/admin/v1/billing/edition') self.assertEqual(json.loads(response['body']), { 'edition': 'PLATFORM', 'account_id': self.client.account_id, }) def test_set_enterprise_billing_edition(self): """Test to set ENTERPRISE billing edition """ response = self.client.set_edition('ENTERPRISE') uri = response['uri'] self.assertEqual(response['method'], 'POST') self.assertEqual(uri, '/admin/v1/billing/edition') self.assertEqual(json.loads(response['body']), { 'edition': 'ENTERPRISE', 'account_id': self.client.account_id, }) def test_get_telephony_credits(self): """Test to get telephony credits """ response = self.client.get_telephony_credits() uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/billing/telephony_credits') self.assertEqual(util.params_to_dict(args), { 'account_id': [self.client.account_id], }) def test_set_telephony_credits(self): """Test to set telephony credits """ response = self.client.set_telephony_credits(10) uri = response['uri'] self.assertEqual(response['method'], 'POST') self.assertEqual(uri, '/admin/v1/billing/telephony_credits') self.assertEqual(json.loads(response['body']), { 'credits': '10', 'account_id': self.client.account_id, }) duo_client_python-5.4.0/tests/admin/000077500000000000000000000000001475537715400175225ustar00rootroot00000000000000duo_client_python-5.4.0/tests/admin/__init__.py000066400000000000000000000000001475537715400216210ustar00rootroot00000000000000duo_client_python-5.4.0/tests/admin/base.py000066400000000000000000000040131475537715400210040ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin import os class TestAdmin(unittest.TestCase): TEST_RESOURCES_DIR = dir_path = os.path.join( os.path.abspath(os.curdir), 'tests', 'resources') def setUp(self): self.client = duo_client.admin.Admin( 'test_ikey', 'test_akey', 'example.com') self.client.account_id = 'DA012345678901234567' # monkeypatch client's _connect() self.client._connect = lambda: util.MockHTTPConnection() # if you are wanting to simulate getting lists of objects # rather than a single object self.client_list = duo_client.admin.Admin( 'test_ikey', 'test_akey', 'example.com') self.client_list.account_id = 'DA012345678901234567' self.client_list._connect = \ lambda: util.MockHTTPConnection(data_response_should_be_list=True) # if you are wanting to get a response from a call to get # authentication logs self.client_authlog = duo_client.admin.Admin( 'test_ikey', 'test_akey', 'example.com') self.client_authlog.account_id = 'DA012345678901234567' self.client_authlog._connect = \ lambda: util.MockHTTPConnection(data_response_from_get_authlog=True) # client to simulate basic structure of a call to fetch Duo Trust # Monitor events. self.client_dtm = duo_client.admin.Admin( 'test_ikey', 'test_akey', 'example.com', ) self.client_dtm.account_id = 'DA012345678901234567' self.client_dtm._connect = \ lambda: util.MockHTTPConnection(data_response_from_get_dtm_events=True) self.items_response_client = duo_client.admin.Admin( 'test_ikey', 'test_akey', 'example.com') self.items_response_client.account_id = 'DA012345678901234567' self.items_response_client._connect = \ lambda: util.MockHTTPConnection(data_response_from_get_items=True) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_activity.py000066400000000000000000000026171475537715400227750ustar00rootroot00000000000000from .. import util from .base import TestAdmin from datetime import datetime, timedelta from freezegun import freeze_time import pytz class TestEndpoints(TestAdmin): def test_get_activity_log(self): """ Test to get activities log. """ response = self.items_response_client.get_activity_logs(maxtime='1663131599000', mintime='1662958799000') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/logs/activity') self.assertEqual( util.params_to_dict(args)['account_id'], [self.items_response_client.account_id]) @freeze_time('2022-10-01') def test_get_activity_log_with_no_args(self): freezed_time = datetime(2022,10,1,0,0,0, tzinfo=pytz.utc) expected_mintime = str(int((freezed_time-timedelta(days=180)).timestamp()*1000)) expected_maxtime = str(int(freezed_time.timestamp() * 1000)) response = self.items_response_client.get_activity_logs() uri, args = response['uri'].split('?') param_dict = util.params_to_dict(args) self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/logs/activity') self.assertEqual( param_dict['mintime'], [expected_mintime]) self.assertEqual( param_dict['maxtime'], [expected_maxtime]) duo_client_python-5.4.0/tests/admin/test_administrative_units.py000066400000000000000000000063721475537715400254100ustar00rootroot00000000000000from .. import util from .base import TestAdmin class TestAdminUnits(TestAdmin): # Uses underlying paging def test_get_administratrive_units(self): response = self.client_list.get_administrative_units() response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/administrative_units') def test_get_administrative_units_with_limit(self): response = self.client_list.get_administrative_units(limit=20) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/administrative_units') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_adminstrative_units_with_limit_offset(self): response = self.client_list.get_administrative_units(limit=20, offset=2) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/administrative_units') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_administrative_units_with_offset(self): response = self.client_list.get_administrative_units(offset=9001) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/administrative_units') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_administrative_units_iterator(self): expected_path = '/admin/v1/administrative_units' expected_method = 'GET' tests = [ { 'admin_id': 'aaaa', 'group_id': '1234', 'integration_key': 'aaaaaaaaaaaaaaaaaaaa', }, { 'admin_id': 'aaaa', 'group_id': '1234', }, { 'admin_id': 'aaaa', }, {} ] for test in tests: response = ( self.client_list.get_administrative_units_iterator(**test) ) response = next(response) self.assertEqual(response['method'], expected_method) (uri, args) = response['uri'].split('?') self.assertEqual(uri, expected_path) expected_params = { key: [value] for (key, value) in test.items() } expected_params.update( { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], } ) self.assertEqual(util.params_to_dict(args), expected_params) duo_client_python-5.4.0/tests/admin/test_admins.py000066400000000000000000000150361475537715400224130ustar00rootroot00000000000000import json from .. import util import duo_client.admin from .base import TestAdmin class TestAdmins(TestAdmin): # Uses underlying paging def test_get_admins(self): response = self.client_list.get_admins() response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_admins_with_limit(self): response = self.client_list.get_admins(limit='20') response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_admins_with_limit_offset(self): response = self.client_list.get_admins(limit='20', offset='2') response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_admins_with_offset(self): response = self.client_list.get_admins(offset=9001) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_admins_iterator(self): response = self.client_list.get_admins_iterator() response = next(response) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_external_password_mgmt_statuses(self): response = self.client_list.get_external_password_mgmt_statuses() response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins/password_mgmt') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_external_password_mgmt_statuses_with_limit(self): response = self.client_list.get_external_password_mgmt_statuses( limit='20') response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins/password_mgmt') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_external_password_mgmt_statusesg_with_limit_offset(self): response = self.client_list.get_external_password_mgmt_statuses( limit='20', offset='2') response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins/password_mgmt') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_external_password_mgmt_statuses_with_offset(self): response = self.client_list.get_external_password_mgmt_statuses( offset=9001) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins/password_mgmt') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_external_password_mgmt_status_for_admin(self): response = self.client_list.get_external_password_mgmt_status_for_admin( 'DFAKEADMINID') response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/admins/DFAKEADMINID/password_mgmt') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], }) def test_update_admin_password_mgmt_status(self): response = self.client_list.update_admin_password_mgmt_status( 'DFAKEADMINID', has_external_password_mgmt='False') response = response[0] self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/admins/DFAKEADMINID/password_mgmt') self.assertEqual( json.loads(response['body']), { 'account_id': self.client.account_id, 'has_external_password_mgmt': 'False' }) def test_update_admin_password_mgmt_status_set_password(self): response = self.client_list.update_admin_password_mgmt_status( 'DFAKEADMINID', has_external_password_mgmt='True', password='dolphins') response = response[0] self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/admins/DFAKEADMINID/password_mgmt') self.assertEqual( json.loads(response['body']), { 'account_id': self.client.account_id, 'has_external_password_mgmt': 'True', 'password': 'dolphins' }) duo_client_python-5.4.0/tests/admin/test_authlog.py000066400000000000000000000020601475537715400225740ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestEndpoints(TestAdmin): def test_get_authentication_log_v1(self): """ Test to get authentication log on version 1 api. """ response = self.client_list.get_authentication_log(api_version=1)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/logs/authentication') self.assertEqual( util.params_to_dict(args)['account_id'], [self.client_list.account_id]) def test_get_authentication_log_v2(self): """ Test to get authentication log on version 1 api. """ response = self.client_authlog.get_authentication_log(api_version=2) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/logs/authentication') self.assertEqual( util.params_to_dict(args)['account_id'], [self.client_authlog.account_id]) duo_client_python-5.4.0/tests/admin/test_bypass_codes.py000066400000000000000000000065141475537715400236170ustar00rootroot00000000000000import duo_client.admin from .. import util from .base import TestAdmin class TestBypassCodes(TestAdmin): def test_delete_bypass_code_by_id(self): """ Test to delete a bypass code by id. """ response = self.client.delete_bypass_code_by_id('DU012345678901234567') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'DELETE') self.assertEqual(uri, '/admin/v1/bypass_codes/DU012345678901234567') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_get_bypass_codes_generator(self): """ Test to get bypass codes generator. """ generator = self.client_list.get_bypass_codes_generator() response = next(generator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_bypass_codes(self): """ Test to get bypass codes without params. """ response = self.client_list.get_bypass_codes()[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_bypass_codes_limit(self): """ Test to get bypass codes with limit. """ response = self.client_list.get_bypass_codes(limit='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_bypass_codes_offset(self): """ Test to get bypass codes with offset. """ response = self.client_list.get_bypass_codes(offset='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_bypass_codes_limit_offset(self): """ Test to get bypass codes with limit and offset. """ response = self.client_list.get_bypass_codes(limit='20', offset='2')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['2'], }) duo_client_python-5.4.0/tests/admin/test_desktop_tokens.py000066400000000000000000000056001475537715400241700ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestDesktopTokens(TestAdmin): def test_get_desktoptokens_generator(self): """ Test to get desktop tokens generator. """ generator = self.client_list.get_desktoptokens_generator() response = next(generator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/desktoptokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_desktoptokens(self): """ Test to get desktop tokens without params. """ response = self.client_list.get_desktoptokens()[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/desktoptokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_desktoptokens_limit(self): """ Test to get desktop tokens with limit. """ response = self.client_list.get_desktoptokens(limit='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/desktoptokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_desktoptokens_offset(self): """ Test to get desktop tokens with offset. """ response = self.client_list.get_desktoptokens(offset='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/desktoptokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_desktoptokens_limit_offset(self): """ Test to get desktop tokens with limit and offset. """ response = self.client_list.get_desktoptokens(limit='20', offset='2')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/desktoptokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['2'], }) duo_client_python-5.4.0/tests/admin/test_endpoints.py000066400000000000000000000065121475537715400231420ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin from .base import TestAdmin class TestEndpoints(TestAdmin): def test_get_endpoints_iterator(self): """ Test to get endpoints iterator """ iterator = self.client_list.get_endpoints_iterator() response = next(iterator) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/endpoints') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_endpoints(self): """ Test to get endpoints. """ response = self.client_list.get_endpoints()[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/endpoints') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_endpoints_offset(self): """ Test to get endpoints with pagination params. """ response = self.client_list.get_endpoints(offset=20)[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/endpoints') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_endpoints_limit(self): """ Test to get endpoints with pagination params. """ response = self.client_list.get_endpoints(limit=20)[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/endpoints') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_endpoints_limit_and_offset(self): """ Test to get endpoints with pagination params. """ response = self.client_list.get_endpoints(limit=35, offset=20)[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/endpoints') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['35'], 'offset': ['20'], }) def test_get_endpoint(self): """ Test getting a single endpoint. """ epkey = 'EP18JX1A10AB102M2T2X' response = self.client_list.get_endpoint(epkey)[0] (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/endpoints/' + epkey) self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], }) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_groups.py000066400000000000000000000175211475537715400224600ustar00rootroot00000000000000import json import warnings from .. import util import duo_client.admin from .base import TestAdmin class TestGroups(TestAdmin): def test_get_groups_generator(self): """ Test to get groups generator. """ generator = self.client_list.get_groups_generator() response = next(generator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/groups') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_groups(self): """ Test to get groups without params. """ response = self.client_list.get_groups()[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/groups') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_groups_limit(self): """ Test to get groups with limit. """ response = self.client_list.get_groups(limit='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/groups') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_groups_offset(self): """ Test to get groups with offset. """ response = self.client_list.get_groups(offset='2')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/groups') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_groups_limit_offset(self): """ Test to get groups with limit and offset. """ response = self.client_list.get_groups(limit='20', offset='2')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/groups') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_group_v1(self): """ Test for v1 API of getting specific group details """ with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") response = self.client.get_group('ABC123', api_version=1) uri, args = response['uri'].split('?') # Assert deprecation warning generated self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, DeprecationWarning)) self.assertIn('Please migrate to the v2 API', str(w[0].message)) self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/groups/ABC123') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_get_group_v2(self): """ Test for v2 API of getting specific group details """ response = self.client.get_group('ABC123', api_version=2) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/groups/ABC123') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_get_group_users(self): """ Test for getting list of users associated with a group """ response = self.client_list.get_group_users('ABC123')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/groups/ABC123/users') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_group_users_with_offset(self): """Test to get users by group id with pagination params """ response = self.client_list.get_group_users('ABC123', offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/groups/ABC123/users') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_group_users_with_limit(self): """Test to get users by group id with pagination params """ response = self.client_list.get_group_users('ABC123', limit=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/groups/ABC123/users') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['30'], 'offset': ['0'], }) def test_get_group_users_with_limit_and_offset(self): """Test to get users by group id with pagination params """ response = self.client_list.get_group_users( 'ABC123', limit=30, offset=60)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/groups/ABC123/users') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['30'], 'offset': ['60'], }) def test_get_group_users_iterator(self): """Test to get user iterator by group id """ iterator = self.client_list.get_group_users_iterator( 'ABC123') response = next(iterator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/groups/ABC123/users') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_delete_group(self): """ Test for deleting a group """ response = self.client.delete_group('ABC123') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'DELETE') self.assertEqual(uri, '/admin/v1/groups/ABC123') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_modify_group(self): """ Test for modifying a group """ response = self.client.modify_group('ABC123') self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/groups/ABC123') self.assertEqual(json.loads(response['body']), {'account_id': self.client.account_id}) duo_client_python-5.4.0/tests/admin/test_integration.py000066400000000000000000000051651475537715400234650ustar00rootroot00000000000000import unittest from .. import util import json import duo_client.admin from .base import TestAdmin class TestIntegration(TestAdmin): def setUp(self): super(TestIntegration, self).setUp() self.integration_key = "DISRYL7L8LZ5YXNWKGNK" def test_get_integration(self): response = self.client.get_integration(self.integration_key) (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v3/integrations/{}'.format(self.integration_key)) self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_delete_integration(self): response = self.client.delete_integration(self.integration_key) (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'DELETE') self.assertEqual(uri, '/admin/v3/integrations/{}'.format(self.integration_key)) self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_create_integration(self): response = self.client.create_integration( name="New integration name", integration_type="sso-generic", sso={ "idp_metadata": None, "saml_config": {} }, ) self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v3/integrations') self.assertEqual(json.loads(response['body']), { "account_id": self.client.account_id, "name": "New integration name", "type": "sso-generic", "sso": { "idp_metadata": None, "saml_config": {} }, } ) def test_update_integration_success(self): response = self.client.update_integration( self.integration_key, name="Integration name", sso={ "saml_config": { "nameid_attribute": "mail", } }, ) self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v3/integrations/{}'.format(self.integration_key)) self.assertEqual(json.loads(response['body']), { "account_id": self.client.account_id, "name": "Integration name", "sso": { "saml_config": { "nameid_attribute": "mail", } }, } ) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_integrations.py000066400000000000000000000057561475537715400236560ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestIntegrations(TestAdmin): def test_get_integrations_generator(self): """ Test to get integrations generator. """ generator = self.client_list.get_integrations_generator() response = next(generator) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v3/integrations') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_integrations(self): """ Test to get integrations without pagination params. """ response = self.client_list.get_integrations() response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v3/integrations') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_integrations_with_limit(self): """ Test to get integrations with pagination params. """ response = self.client_list.get_integrations(limit=20) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v3/integrations') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_integrations_with_limit_offset(self): """ Test to get integrations with pagination params. """ response = self.client_list.get_integrations(limit=20, offset=2) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v3/integrations') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_integrations_with_offset(self): """ Test to get integrations with pagination params. """ response = self.client_list.get_integrations(offset=9001) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v3/integrations') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) duo_client_python-5.4.0/tests/admin/test_logo.py000066400000000000000000000013771475537715400221030ustar00rootroot00000000000000import json from .base import TestAdmin import os import base64 import urllib.parse class TestLogo(TestAdmin): def test_update_logo(self): # Get an image to upload: logo_path = os.path.join(self.TEST_RESOURCES_DIR, "barn-owl-small.png") with open(logo_path, "rb") as image_file: logo_file = image_file.read() # Call function in client: response = self.client.update_logo(logo_file) # Prep validation text: base64_logo = base64.b64encode(logo_file) base64_logo = urllib.parse.quote_plus(base64_logo) # Validate response: self.assertTrue( json.loads(response['body']).get('logo'), base64_logo ) duo_client_python-5.4.0/tests/admin/test_passport.py000066400000000000000000000020071475537715400230050ustar00rootroot00000000000000import json from .base import TestAdmin from .. import util class TestPassport(TestAdmin): def test_get_passport(self): """ Test get passport configuration """ response = self.client.get_passport_config() (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/passport/config') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) def test_update_passport(self): """ Test update passport configuration """ response = self.client.update_passport_config(enabled_status="enabled-for-groups", enabled_groups=["passport-test-group"]) self.assertEqual(response["uri"], "/admin/v2/passport/config") body = json.loads(response["body"]) self.assertEqual(body["enabled_status"], "enabled-for-groups") self.assertEqual(body["enabled_groups"], ["passport-test-group"]) self.assertEqual(body["disabled_groups"], []) duo_client_python-5.4.0/tests/admin/test_phones.py000066400000000000000000000055601475537715400224350ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestPhones(TestAdmin): def test_get_phones_generator(self): """ Test to get phones generator. """ generator = self.client_list.get_phones_generator() response = next(generator) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/phones') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_phones(self): """ Test to get phones without pagination params. """ response = self.client_list.get_phones() response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/phones') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_phones_with_limit(self): """ Test to get phones with pagination params. """ response = self.client_list.get_phones(limit=20) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/phones') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_phones_with_limit_offset(self): """ Test to get phones with pagination params. """ response = self.client_list.get_phones(limit=20, offset=2) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/phones') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_phones_with_offset(self): """ Test to get phones with pagination params. """ response = self.client_list.get_phones(offset=9001) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/phones') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) duo_client_python-5.4.0/tests/admin/test_policies.py000066400000000000000000000077731475537715400227600ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestPolicies(TestAdmin): def setUp(self): super(TestPolicies, self).setUp() self.client.account_id = None self.client_list.account_id = None def test_delete_policy_v2(self): policy_key = "POSTGY2G0HVWJR4JO1XT" response = self.client.delete_policy_v2(policy_key) uri, _ = response["uri"].split("?") self.assertEqual(response["method"], "DELETE") self.assertEqual(uri, "/admin/v2/policies/{}".format(policy_key)) def test_get_policy_v2(self): policy_key = "POSTGY2G0HVWJR4JO1XT" response = self.client.get_policy_v2(policy_key) uri, _ = response["uri"].split("?") self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/policies/{}".format(policy_key)) def test_update_policy_v2(self): policy_key = "POSTGY2G0HVWJR4JO1XT" policy_settings = { "sections": { "browsers": { "blocked_browsers_list": ["ie"], } } } response = self.client.update_policy_v2(policy_key, policy_settings) self.assertEqual(response["method"], "PUT") self.assertEqual(response["uri"], "/admin/v2/policies/{}".format(policy_key)) def test_update_policies_v2(self): edit_list = ["POSTGY2G0HVWJR4JO1XT", "POSTGY2G0HVWJR4JO1XU"] sections = { "browsers": { "blocked_browsers_list": ["ie"], } } response = self.client.update_policies_v2(sections, [], edit_list) self.assertEqual(response["method"], "PUT") self.assertEqual(response["uri"], "/admin/v2/policies/update") def test_create_policy_v2(self): policy_settings = { "name": "my new policy", "sections": { "browsers": { "blocked_browsers_list": ["ie"], } }, } response = self.client.create_policy_v2(policy_settings) self.assertEqual(response["method"], "POST") self.assertEqual(response["uri"], "/admin/v2/policies") def test_copy_policy_v2(self): policy_key = "POSTGY2G0HVWJR4JO1XT" new_policy_names_list = ["Copied Pol 1", "Copied Pol 2"] response = self.client.copy_policy_v2(policy_key, new_policy_names_list) self.assertEqual(response["method"], "POST") self.assertEqual(response["uri"], "/admin/v2/policies/copy") def test_get_policies_v2(self): response = self.client.get_policies_v2(limit=3, offset=0) uri, args = response["uri"].split("?") self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/policies") self.assertDictEqual( util.params_to_dict(args), {"limit": ["3"], "offset": ["0"]} ) def test_get_policies_v2_iterator(self): iterator = self.client_list.get_policies_v2_iterator() response = next(iterator) uri, args = response["uri"].split("?") self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/policies") self.assertDictEqual( util.params_to_dict(args), {"limit": ["100"], "offset": ["0"]} ) def test_get_policy_summary(self): response = self.client.get_policy_summary_v2() uri, _ = response["uri"].split("?") self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/policies/summary") def test_calculate_policy(self): ikey = "DI82WWNVI5Z4V10LZJR6" ukey = "DUQU89MDEWOUR277H44G" response = self.client.calculate_policy(integration_key=ikey, user_id=ukey) uri, args = response["uri"].split("?") self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/policies/calculate") self.assertDictEqual( util.params_to_dict(args), {"integration_key": [ikey], "user_id": [ukey]} )duo_client_python-5.4.0/tests/admin/test_registered_devices.py000066400000000000000000000061011475537715400247700ustar00rootroot00000000000000from .base import TestAdmin from .. import util class TestRegisteredDevices(TestAdmin): def test_get_registered_devices_generator(self): """ Test to get desktop tokens generator. """ generator = self.client_list.get_registered_devices_generator() response = next(generator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/registered_devices') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_registered_devices(self): """ Test to get desktop tokens without params. """ response = self.client_list.get_registered_devices()[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/registered_devices') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_registered_devices_limit(self): """ Test to get desktop tokens with limit. """ response = self.client_list.get_registered_devices(limit='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/registered_devices') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_registered_devices_offset(self): """ Test to get desktop tokens with offset. """ response = self.client_list.get_registered_devices(offset='20')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/registered_devices') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_registered_devices_limit_offset(self): """ Test to get desktop tokens with limit and offset. """ response = self.client_list.get_registered_devices(limit='20', offset='2')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/registered_devices') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client_list.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_delete_registered_device(self): """ Test to delete registered device by registered device id. """ response = self.client.delete_registered_device('CRSFWW1YWVNUXMBJ1J29') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'DELETE') self.assertEqual(uri, '/admin/v1/registered_devices/CRSFWW1YWVNUXMBJ1J29') self.assertEqual(util.params_to_dict(args), {'account_id': [self.client.account_id]}) duo_client_python-5.4.0/tests/admin/test_settings.py000066400000000000000000000072021475537715400227740ustar00rootroot00000000000000import json from .. import util import duo_client.admin from .base import TestAdmin class TestSettings(TestAdmin): def test_update_settings(self): """ Test updating settings """ self.maxDiff = None response = self.client_list.update_settings( lockout_threshold=10, lockout_expire_duration=60, inactive_user_expiration=30, pending_deletion_days=5, log_retention_days=180, sms_batch=5, sms_expiration=60, sms_refresh=True, sms_message='test_message', fraud_email='test@example.com', fraud_email_enabled=True, keypress_confirm='0', keypress_fraud='9', timezone='UTC', telephony_warning_min=50, caller_id='+15035551000', user_telephony_cost_max=10, minimum_password_length=12, password_requires_upper_alpha=True, password_requires_lower_alpha=True, password_requires_numeric=True, password_requires_special=True, helpdesk_bypass="allow", helpdesk_bypass_expiration=60, helpdesk_message="test_message", helpdesk_can_send_enroll_email=True, reactivation_url="https://www.example.com", reactivation_integration_key='DINTEGRATIONKEYTEST0', security_checkup_enabled=True, user_managers_can_put_users_in_bypass=False, email_activity_notification_enabled=True, push_activity_notification_enabled=True, unenrolled_user_lockout_threshold=100, enrollment_universal_prompt_enabled=True, ) response = response[0] self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/settings') self.assertEqual( json.loads(response['body']), { 'account_id': self.client.account_id, 'lockout_threshold': '10', 'lockout_expire_duration': '60', 'inactive_user_expiration': '30', 'log_retention_days': '180', 'sms_batch': '5', 'sms_expiration': '60', 'sms_refresh': '1', 'sms_message': 'test_message', 'fraud_email': 'test@example.com', 'fraud_email_enabled': '1', 'keypress_confirm': '0', 'keypress_fraud': '9', 'timezone': 'UTC', 'telephony_warning_min': '50', 'caller_id': '+15035551000', 'user_telephony_cost_max': '10', 'pending_deletion_days': '5', 'minimum_password_length': '12', 'password_requires_upper_alpha': '1', 'password_requires_lower_alpha': '1', 'password_requires_numeric': '1', 'password_requires_special': '1', 'helpdesk_bypass': 'allow', 'helpdesk_bypass_expiration': '60', 'helpdesk_message': 'test_message', 'helpdesk_can_send_enroll_email': '1', 'reactivation_url': 'https://www.example.com', 'reactivation_integration_key': 'DINTEGRATIONKEYTEST0', 'security_checkup_enabled': '1', 'user_managers_can_put_users_in_bypass': '0', 'email_activity_notification_enabled': '1', 'push_activity_notification_enabled': '1', 'unenrolled_user_lockout_threshold': '100', 'enrollment_universal_prompt_enabled': '1', }) duo_client_python-5.4.0/tests/admin/test_telephony.py000066400000000000000000000033421475537715400231440ustar00rootroot00000000000000from .. import util from .base import TestAdmin from datetime import datetime, timedelta from freezegun import freeze_time import pytz class TestTelephonyLogEndpoints(TestAdmin): def test_get_telephony_logs_v2(self): """Test to get activities log.""" response = self.items_response_client.get_telephony_log( maxtime=1663131599000, mintime=1662958799000, api_version=2 ) uri, args = response["uri"].split("?") self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/logs/telephony") self.assertEqual( util.params_to_dict(args)["account_id"], [self.items_response_client.account_id] ) @freeze_time("2022-10-01") def test_get_telephony_logs_v2_no_args(self): freezed_time = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) expected_mintime = str( int((freezed_time - timedelta(days=180)).timestamp() * 1000) ) expected_maxtime = str(int(freezed_time.timestamp() * 1000) - 120) response = self.items_response_client.get_telephony_log(api_version=2) uri, args = response["uri"].split("?") param_dict = util.params_to_dict(args) self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/logs/telephony") self.assertEqual(param_dict["mintime"], [expected_mintime]) self.assertEqual(param_dict["maxtime"], [expected_maxtime]) @freeze_time("2022-10-01") def test_get_telephony_logs_v1_no_args(self): response = self.client_list.get_telephony_log() uri, args = response[0]["uri"].split("?") self.assertEqual(response[0]["method"], "GET") self.assertEqual(uri, "/admin/v1/logs/telephony") duo_client_python-5.4.0/tests/admin/test_tokens.py000066400000000000000000000055601475537715400224440ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestTokens(TestAdmin): def test_get_tokens_generator(self): """ Test to get tokens generator. """ generator = self.client_list.get_tokens_generator() response = next(generator) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/tokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_tokens(self): """ Test to get tokens without pagination params. """ response = self.client_list.get_tokens() response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/tokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_tokens_with_limit(self): """ Test to get tokens with pagination params. """ response = self.client_list.get_tokens(limit=20) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/tokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['0'], }) def test_get_tokens_with_limit_offset(self): """ Test to get tokens with pagination params. """ response = self.client_list.get_tokens(limit=20, offset=2) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/tokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['2'], }) def test_get_tokens_with_offset(self): """ Test to get tokens with pagination params. """ response = self.client_list.get_tokens(offset=9001) response = response[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/tokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) duo_client_python-5.4.0/tests/admin/test_trust_monitor_events.py000066400000000000000000000027361475537715400254570ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin MINTIME = 1603399970000 MAXTIME = 1603399973797 LIMIT = 10 NEXT_OFFSET = "99999" EVENT_TYPE = "auth" class TestTrustMonitorEvents(TestAdmin): def test_get_trust_monitor_events_iterator(self): """ Test to ensure that the correct parameters are supplied when calling next on the generator. """ generator = self.client_dtm.get_trust_monitor_events_iterator( MINTIME, MAXTIME, event_type=EVENT_TYPE, ) events = [e for e in generator] self.assertEqual(events, [{"foo": "bar"},{"bar": "foo"}]) def test_get_trust_monitor_events_by_offset(self): """ Test to ensure that the correct parameters are supplied. """ res = self.client_list.get_trust_monitor_events_by_offset( MINTIME, MAXTIME, limit=LIMIT, offset=NEXT_OFFSET, event_type=EVENT_TYPE, )[0] uri, qry_str = res["uri"].split("?") args = util.params_to_dict(qry_str) self.assertEqual(res["method"], "GET") self.assertEqual(uri, "/admin/v1/trust_monitor/events") self.assertEqual(args["mintime"], ["1603399970000"]) self.assertEqual(args["maxtime"], ["1603399973797"]) self.assertEqual(args["offset"], ["99999"]) self.assertEqual(args["limit"], ["10"]) self.assertEqual(args["type"], ["auth"]) duo_client_python-5.4.0/tests/admin/test_u2f.py000066400000000000000000000061551475537715400216360ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestU2F(TestAdmin): def test_get_u2ftokens_with_params(self): """ Test to get u2ftokens with params. """ response = list(self.client_list.get_u2ftokens(limit=8))[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/u2ftokens') self.assertEqual( util.params_to_dict(args), { 'account_id':[self.client_list.account_id], 'limit': ['8'], 'offset': ['0'], } ) def test_get_u2ftokens_iterator(self): response = self.client_list.get_u2ftokens_iterator() response = next(response) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/u2ftokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'] } ) def test_get_u2ftokens_without_params(self): """ Test to get u2ftokens without params. """ response = list(self.client_list.get_u2ftokens())[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/u2ftokens') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], } ) def test_get_u2ftokens_with_offset(self): response = list(self.client_list.get_u2ftokens(limit=2, offset=3))[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/u2ftokens') self.assertEqual( util.params_to_dict(args), { 'account_id':[self.client_list.account_id], 'limit': ['2'], 'offset': ['3'] } ) def test_get_u2ftoken_by_id(self): """ Test to get u2ftoken by registration id. """ response = self.client.get_u2ftoken_by_id('DU012345678901234567') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/u2ftokens/DU012345678901234567') self.assertEqual(util.params_to_dict(args), {'account_id':[self.client.account_id]}) def test_delete_u2ftoken(self): """ Test to delete u2ftoken by registration id. """ response = self.client.delete_u2ftoken('DU012345678901234567') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'DELETE') self.assertEqual(uri, '/admin/v1/u2ftokens/DU012345678901234567') self.assertEqual(util.params_to_dict(args), {'account_id':[self.client.account_id]}) duo_client_python-5.4.0/tests/admin/test_user_bypass_codes.py000066400000000000000000000070261475537715400246540ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestUserBypassCodes(TestAdmin): def test_get_user_bypass_codes(self): """ Test to get bypass codes by user id. """ response = self.client_list.get_user_bypass_codes( 'DU012345678901234567')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual( uri, '/admin/v1/users/DU012345678901234567/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_bypass_codes_iterator(self): """ Test to get bypass codes iterator by user id. """ iterator = self.client_list.get_user_bypass_codes_iterator( 'DU012345678901234567') response = next(iterator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual( uri, '/admin/v1/users/DU012345678901234567/bypass_codes') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_bypass_codes_with_offset(self): """ Test to get bypass codes by user id with pagination params. """ response = self.client_list.get_user_bypass_codes( 'DU012345678901234567', offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual( uri, '/admin/v1/users/DU012345678901234567/bypass_codes') self.assertEqual(util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_bypass_codes_with_limit(self): """ Test to get bypass codes by user id with pagination params. """ response = self.client_list.get_user_bypass_codes( 'DU012345678901234567', limit=10)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual( uri, '/admin/v1/users/DU012345678901234567/bypass_codes') self.assertEqual(util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['10'], 'offset': ['0'], }) def test_get_user_bypass_codes_with_limit_and_offset(self): """ Test to get bypass codes by user id with pagination params. """ response = self.client_list.get_user_bypass_codes( 'DU012345678901234567', limit=10, offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual( uri, '/admin/v1/users/DU012345678901234567/bypass_codes') self.assertEqual(util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['10'], 'offset': ['30'], }) duo_client_python-5.4.0/tests/admin/test_user_groups.py000066400000000000000000000066711475537715400235220ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin from .base import TestAdmin class TestUserGroups(TestAdmin): def test_get_user_groups_iterator(self): """ Test to get groups iterator by user id. """ generator = self.client_list.get_user_groups_iterator( 'DU012345678901234567') response = next(generator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_groups(self): """ Test to get groups by user id. """ response = self.client_list.get_user_groups('DU012345678901234567')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_groups_with_offset(self): """ Test to get groups by user id with pagination params. """ response = self.client_list.get_user_groups( 'DU012345678901234567', offset=60)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_groups_with_limit(self): """ Test to get groups by user id with pagination params. """ response = self.client_list.get_user_groups( 'DU012345678901234567', limit=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['30'], 'offset': ['0'], }) def test_get_user_groups_with_limit_and_offset(self): """ Test to get groups by user id with pagination params. """ response = self.client_list.get_user_groups( 'DU012345678901234567', limit=30, offset=60)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/groups') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['30'], 'offset': ['60'], }) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_user_phones.py000066400000000000000000000066401475537715400234730ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin from .base import TestAdmin class TestUserPhones(TestAdmin): def test_get_user_phones_iterator(self): """Test to get phones iterator by user id """ iterator = self.client_list.get_user_phones_iterator( 'DU012345678901234567') response = next(iterator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_phones(self): """Test to get phones by user id """ response = self.client_list.get_user_phones('DU012345678901234567')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_phones_with_offset(self): """Test to get phones by user id with pagination params """ response = self.client_list.get_user_phones( 'DU012345678901234567', offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_phones_with_limit(self): """Test to get phones by user id with pagination params """ response = self.client_list.get_user_phones( 'DU012345678901234567', limit=10)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['0'], }) def test_get_user_phones_with_limit_and_offset(self): """Test to get phones by user id with pagination params """ response = self.client_list.get_user_phones( 'DU012345678901234567', limit=10, offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/phones') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['30'], }) if __name__ == '__main': unittest.main() duo_client_python-5.4.0/tests/admin/test_user_tokens.py000066400000000000000000000066411475537715400235030ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin from .base import TestAdmin class TestUserTokens(TestAdmin): def test_get_user_tokens_iterator(self): """ Test to get tokens iterator by user id. """ generator = self.client_list.get_user_tokens_iterator( 'DU012345678901234567') response = next(generator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_tokens(self): """ Test to get tokens by user id. """ response = self.client_list.get_user_tokens('DU012345678901234567')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_tokens_offset(self): """ Test to get tokens by user id with pagination params. """ response = self.client_list.get_user_tokens( 'DU012345678901234567', offset=100)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_tokens_limit(self): """ Test to get tokens by user id with pagination params. """ response = self.client_list.get_user_tokens( 'DU012345678901234567', limit=500)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['500'], 'offset': ['0'], }) def test_get_user_tokens_limit_and_offset(self): """ Test to get tokens by user id with pagination params. """ response = self.client_list.get_user_tokens( 'DU012345678901234567', limit=10, offset=100)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/tokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['100'], }) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_user_u2f.py000066400000000000000000000071411475537715400226700ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin from .base import TestAdmin class TestUserU2F(TestAdmin): def test_get_user_u2ftokens_iterator(self): """ Test to get u2ftokens iterator by user id. """ iterator = self.client_list.get_user_u2ftokens_iterator( 'DU012345678901234567') response = next(iterator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_u2ftokens(self): """ Test to get u2ftokens by user id. """ response = self.client_list.get_user_u2ftokens( 'DU012345678901234567')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_u2ftokens_with_offset(self): """ Test to get u2ftokens by user id with pagination params. """ response = self.client_list.get_user_u2ftokens('DU012345678901234567', offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_u2ftokens_with_limit(self): """ Test to get u2ftokens by user id with pagination params. """ response = self.client_list.get_user_u2ftokens('DU012345678901234567', limit=10)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['0'], }) def test_get_user_u2ftokens_with_limit_and_offset(self): """ Test to get u2ftokens by user id with pagination params. """ response = self.client_list.get_user_u2ftokens('DU012345678901234567', limit=10, offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/u2ftokens') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['30'], }) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_user_webauthn.py000066400000000000000000000074671475537715400240240ustar00rootroot00000000000000import unittest from .. import util import duo_client.admin from .base import TestAdmin class TestUserTestWebauthn(TestAdmin): def test_get_user_webauthncredentials_iterator(self): """ Test to get webauthn credentials iterator by user id. """ iterator = self.client_list.get_user_webauthncredentials_iterator( 'DU012345678901234567') response = next(iterator) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_webauthncredentials(self): """ Test to get webauthn credentials by user id. """ response = self.client_list.get_user_webauthncredentials( 'DU012345678901234567')[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_webauthncredentials_with_offset(self): """ Test to get webauthn credentials by user id with pagination params. """ response = self.client_list.get_user_webauthncredentials('DU012345678901234567', offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_user_webauthncredentials_with_limit(self): """ Test to get webauthn credentials by user id with pagination params. """ response = self.client_list.get_user_webauthncredentials('DU012345678901234567', limit=10)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['0'], }) def test_get_user_webauthncredentials_with_limit_and_offset(self): """ Test to get webauthn credentials by user id with pagination params. """ response = self.client_list.get_user_webauthncredentials('DU012345678901234567', limit=10, offset=30)[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/DU012345678901234567/webauthncredentials') self.assertEqual(util.params_to_dict(args), { 'account_id':[self.client.account_id], 'limit': ['10'], 'offset': ['30'], }) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/admin/test_users.py000066400000000000000000000146351475537715400223050ustar00rootroot00000000000000import json from .. import util import duo_client.admin from .base import TestAdmin class TestUsers(TestAdmin): def test_get_users_generator(self): """ Test to get users iterator. """ iterator = self.client_list.get_users_iterator() response = next(iterator) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_users(self): """ Test to get users. """ response = self.client_list.get_users()[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_users_offset(self): """ Test to get users with pagination params. """ response = self.client_list.get_users(offset=30)[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['100'], 'offset': ['0'], }) def test_get_users_limit(self): """ Test to get users with pagination params. """ response = self.client_list.get_users(limit=30)[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['30'], 'offset': ['0'], }) def test_get_users_limit_and_offset(self): """ Test to get users with pagination params. """ response = self.client_list.get_users(limit=20, offset=30)[0] self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client.account_id], 'limit': ['20'], 'offset': ['30'], }) # GET with params def test_get_users_by_name(self): response = self.client.get_users_by_name('foo') (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), {'username':['foo'], 'account_id':[self.client.account_id]}) self.assertEqual(response['body'], None) response = self.client.get_users_by_name('foo') (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users') self.assertEqual( util.params_to_dict(args), {'username':['foo'], 'account_id':[self.client.account_id]}) self.assertEqual(response['body'], None) # POST with params def test_add_user(self): # all params given response = self.client.add_user( 'foo', realname='bar', status='active', notes='notes', email='foobar@baz.com', firstname='fName', lastname='lName', alias1='alias1', alias2='alias2', alias3='alias3', alias4='alias4') self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/users') self.assertEqual( json.loads(response['body']), { 'realname': 'bar', 'notes': 'notes', 'username': 'foo', 'status': 'active', 'email': 'foobar@baz.com', 'firstname': 'fName', 'lastname': 'lName', 'account_id': self.client.account_id, 'alias1': 'alias1', 'alias2': 'alias2', 'alias3': 'alias3', 'alias4': 'alias4', }) # defaults response = self.client.add_user('bar') self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/users') self.assertEqual( json.loads(response['body']), {'username':'bar', 'account_id':self.client.account_id}) def test_update_user(self): response = self.client.update_user( 'DU012345678901234567', username='foo', realname='bar', status='active', notes='notes', email='foobar@baz.com', firstname='fName', lastname='lName', alias1='alias1', alias2='alias2', alias3='alias3', alias4='alias4') self.assertEqual(response['method'], 'POST') self.assertEqual( response['uri'], '/admin/v1/users/DU012345678901234567') self.assertEqual( json.loads(response['body']), { 'account_id':self.client.account_id, 'realname': 'bar', 'notes': 'notes', 'username': 'foo', 'status': 'active', 'email': 'foobar@baz.com', 'firstname': 'fName', 'lastname': 'lName', 'account_id': self.client.account_id, 'alias1': 'alias1', 'alias2': 'alias2', 'alias3': 'alias3', 'alias4': 'alias4', }) def test_sync_user(self): """ Test to synchronize a single user in a directory for a username. """ response = self.client.sync_user('foo', 'test_dir_key') self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/users/directorysync/test_dir_key/syncuser') self.assertEqual( json.loads(response['body']), {'username': 'foo', 'account_id': self.client.account_id}) duo_client_python-5.4.0/tests/admin/test_verification_push.py000066400000000000000000000022501475537715400246530ustar00rootroot00000000000000import json from .. import util from .base import TestAdmin class TestVerificationPush(TestAdmin): def test_send_verification_push(self): """ Test sending a verification push to a user. """ response = self.client.send_verification_push('test_user_id', 'test_phone_id') self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/admin/v1/users/test_user_id/send_verification_push') self.assertEqual( json.loads(response['body']), {'phone_id': 'test_phone_id', 'account_id': self.client.account_id}) def test_get_verification_push_response(self): """ Test getting the verification push response. """ response = self.client.get_verification_push_response('test_user_id', 'test_push_id') (uri, args) = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/users/test_user_id/verification_push_response') self.assertEqual( util.params_to_dict(args), {'push_id': ['test_push_id'], 'account_id': [self.client.account_id]}) duo_client_python-5.4.0/tests/admin/test_webauthn.py000066400000000000000000000065211475537715400227540ustar00rootroot00000000000000from .. import util import duo_client.admin from .base import TestAdmin class TestWebauthn(TestAdmin): def test_get_webauthncredentials_with_params(self): """ Test to get webauthn credentials with params. """ response = list(self.client_list.get_webauthncredentials(limit=8))[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/webauthncredentials') self.assertEqual( util.params_to_dict(args), { 'account_id':[self.client_list.account_id], 'limit': ['8'], 'offset': ['0'], } ) def test_get_webauthncredentials_iterator(self): response = self.client_list.get_webauthncredentials_iterator() response = next(response) uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/webauthncredentials') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'] } ) def test_get_webauthncredentials_without_params(self): """ Test to get webauthn credentials without params. """ response = list(self.client_list.get_webauthncredentials())[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/webauthncredentials') self.assertEqual( util.params_to_dict(args), { 'account_id': [self.client_list.account_id], 'limit': ['100'], 'offset': ['0'], } ) def test_get_webauthncredentials_with_offset(self): response = list(self.client_list.get_webauthncredentials(limit=2, offset=3))[0] uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/webauthncredentials') self.assertEqual( util.params_to_dict(args), { 'account_id':[self.client_list.account_id], 'limit': ['2'], 'offset': ['3'] } ) def test_get_webauthncredential_by_id(self): """ Test to get webauthn credential by registration id. """ response = self.client.get_webauthncredential_by_id('DU012345678901234567') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v1/webauthncredentials/DU012345678901234567') self.assertEqual(util.params_to_dict(args), {'account_id':[self.client.account_id]}) def test_delete_webauthncredential(self): """ Test to delete webauthn credential by registration id. """ response = self.client.delete_webauthncredential('DU012345678901234567') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'DELETE') self.assertEqual(uri, '/admin/v1/webauthncredentials/DU012345678901234567') self.assertEqual(util.params_to_dict(args), {'account_id':[self.client.account_id]})duo_client_python-5.4.0/tests/resources/000077500000000000000000000000001475537715400204445ustar00rootroot00000000000000duo_client_python-5.4.0/tests/resources/barn-owl-small.png000066400000000000000000000135531475537715400240100ustar00rootroot00000000000000PNG  IHDR szz AiCCPICC ProfileH wTSϽ7" %z ;HQIP&vDF)VdTG"cE b PQDE݌k 5ޚYg}׺PtX4X\XffGD=HƳ.d,P&s"7C$ E6<~&S2)212 "įl+ɘ&Y4Pޚ%ᣌ\%g|eTI(L0_&l2E9r9hxgIbטifSb1+MxL 0oE%YmhYh~S=zU&ϞAYl/$ZUm@O ޜl^ ' lsk.+7oʿ9V;?#I3eE妧KD d9i,UQ h A1vjpԁzN6p\W p G@ K0ށiABZyCAP8C@&*CP=#t] 4}a ٰ;GDxJ>,_“@FXDBX$!k"EHqaYbVabJ0՘cVL6f3bձX'?v 6-V``[a;p~\2n5׌ &x*sb|! ߏƿ' Zk! $l$T4QOt"y\b)AI&NI$R$)TIj"]&=&!:dGrY@^O$ _%?P(&OJEBN9J@y@yCR nXZOD}J}/G3ɭk{%Oחw_.'_!JQ@SVF=IEbbbb5Q%O@%!BӥyҸM:e0G7ӓ e%e[(R0`3R46i^)*n*|"fLUo՝mO0j&jajj.ϧwϝ_4갺zj=U45nɚ4ǴhZ ZZ^0Tf%9->ݫ=cXgN].[7A\SwBOK/X/_Q>QG[ `Aaac#*Z;8cq>[&IIMST`ϴ kh&45ǢYYF֠9<|y+ =X_,,S-,Y)YXmĚk]c}džjcΦ浭-v};]N"&1=xtv(}'{'IߝY) Σ -rqr.d._xpUەZM׍vm=+KGǔ ^WWbj>:>>>v}/avO8 FV> 2 u/_$\BCv< 5 ]s.,4&yUx~xw-bEDCĻHGKwFGEGME{EEKX,YFZ ={$vrK .3\rϮ_Yq*©L_wד+]eD]cIIIOAu_䩔)3ѩiB%a+]3='/40CiU@ёL(sYfLH$%Y jgGeQn~5f5wugv5k֮\۹Nw]m mHFˍenQQ`hBBQ-[lllfjۗ"^bO%ܒY}WwvwXbY^Ю]WVa[q`id2JjGէ{׿m>PkAma꺿g_DHGGu;776ƱqoC{P38!9 ҝˁ^r۽Ug9];}}_~imp㭎}]/}.{^=}^?z8hc' O*?f`ϳgC/Oϩ+FFGGόzˌㅿ)ѫ~wgbk?Jި9mdwi獵ޫ?cǑOO?w| x&mf2:Y~diTXtXML:com.adobe.xmp Adobe ImageReady j00W uIDATX WitT~23wL&+YH!!a'n UNjіs{+UR"TPPM@@:YfLfsqm3sg{8l?W0 YU-LIrH4z>R|OZמaFi+x&~ㅞ޺L"$YeYܿqR^|ɩs޸/ع?6@Fs8y9/}{ZUUgpجz)V3s-2n -+STužMEU<3{wSo}׮[k;yɉӸ{# <šc'**J?HX8cqjM׬zkwϞ)@g?j8%TxO[RxɈc 73j; 2 k/v'h$ A,o:>mlFm឵cM0vVq5K@ŝ|6v;IQ=M :;6?VpL70D[ Qir]/\ko8iǺWp6Z:/I4nj =V,MxbvŲ]-a?dy`4%PRLh&ADLj6クT)3T!zz;HTߴ .ؓOD1YaToznʕ|xj)G6}D7ϐܬ *S0# k{C͊Z|RW !/7yŰt~9&&M pKkyC&(ʟ CshnA=6r<㐟;iz\x~n/e +x<Ipqwգe~q Sׇg.TT]-R(65YG'ݨ(&@r23mskt!=]|܈H Y %c? cGVRw'Ew ~u;݈R*3%ULh"ii 7ן^N0xƀp/5[ѡTFG ~6=}-]0 |vnZYZ3j$/ 1G8hȧF8~)GO&H`xGPơ&9!f N ѫecrP<#4ьxs!D1dTGAC9 F袧Ӎeޜ86TΙEU{H26}{Af"'9cXw*Y.aȲRP N@.5W K<;Xh\i6t=kA"'$9[؁k,h"FO0gȰO @ 18WGjVλ9x l?*6 t<SࢺH1q Pٺشīm;)6! 8S/Mh3+T3čh=1T\ L9IM"k@(NGt@I22#=ͭ@絳v !m#OI1*9DLA $ta\zJR繿mzLe^Ղ P dHF`RZ|݂/a)xoh!T/[!47$޺[`]$9d0T`FJ3׊{y+za:UH| ݕ`pEplb%NmG '!=ggzp17z3N H90%V' D$U-fUW |b7arWt~L V$.  jP1ohLFa#At}qEbJYb( g ~ᮆ3ͨseE GQR# ӹ7d$=Cwl.,=8 J pp5/C{rIMĦ,ϼԶv})ZtΪz%np\)HCXYn0 hc`@~܃u(N@%v ZhY;oiAk9+Yf͒60_Vţi-` bMv`Dli4F&ml4;g;wD[倅q ue|$<Í42YSBa+5T/LYQXv! N>a$Pst/.5T2%$>-X >d2r>YwXi3(Vl11@.8-aոlKkwm|pWh6USvbjR+. - E S0LC" [13 l6!0riseq=^^m/_ @J[w qWI,'VY iK-avisDb2=DKy%bgcM?ھiw##ѦF$N}}[dОp>Qvy|\M՝#s aTd,L*cY3JrЎ=4ʎ%M'eFIENDB`duo_client_python-5.4.0/tests/test_client.py000066400000000000000000001015451475537715400213270ustar00rootroot00000000000000import hashlib from unittest import mock import unittest import duo_client.client from . import util import base64 import collections import json JSON_BODY = { 'data': 'abc123', 'alpha': ['a', 'b', 'c', 'd'], 'info': { 'test': 1, 'another': 2, } } JSON_STRING = '{"alpha":["a","b","c","d"],"data":"abc123","info":{"another":2,"test":1}}' class TestQueryParameters(unittest.TestCase): """ Tests for the proper canonicalization of query parameters for signing. """ def assert_canon_params(self, params, expected): params = duo_client.client.normalize_params(params) self.assertEqual( duo_client.client.canon_params(params), expected, ) def test_zero_params(self): self.assert_canon_params( {}, '', ) def test_one_param(self): self.assert_canon_params( {'realname': ['First Last']}, 'realname=First%20Last', ) def test_two_params(self): self.assert_canon_params( {'realname': ['First Last'], 'username': ['root']}, 'realname=First%20Last&username=root') def test_with_boolean_true_int_and_string(self): self.assert_canon_params( {'words': ['First Last'], 'success': [True], 'digit': [5]}, 'digit=5&success=true&words=First%20Last') def test_with_boolean_false_int_and_string(self): self.assert_canon_params( {'words': ['First Last'], 'success': [False], 'digit': [5]}, 'digit=5&success=false&words=First%20Last') def test_list_string(self): """ A list and a string will both get converted. """ self.assert_canon_params( {'realname': 'First Last', 'username': ['root']}, 'realname=First%20Last&username=root') def test_printable_ascii_characters(self): self.assert_canon_params( { 'digits': ['0123456789'], 'letters': ['abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'], 'punctuation': ['!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'], 'whitespace': ['\t\n\x0b\x0c\r '], }, 'digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20' ) def test_unicode_fuzz_values(self): self.assert_canon_params( { u'bar': [u'\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1'], u'baz': [u'\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c'], u'foo': [u'\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655'], u'qux': [u'\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be'], }, 'bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE', ) def test_unicode_fuzz_keys_and_values(self): self.assert_canon_params( { u'\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170': [u'\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0'], u'\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813': [u'\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30'], u'\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042': [u'\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3'], u'\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934': [u'\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU'], }, '%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU', ) def test_sort_order_with_common_prefix(self): self.assert_canon_params( { 'foo_bar': '2', 'foo': '1', }, 'foo=1&foo_bar=2', ) class TestCanonicalize(unittest.TestCase): """ Tests of the canonicalization of request attributes and parameters for signing. """ def test_v1(self): test = { 'host': 'foO.BAr52.cOm', 'method': 'PoSt', 'params': { u'\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170': [u'\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0'], u'\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813': [u'\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30'], u'\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042': [u'\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3'], u'\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934': [u'\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU'], }, 'uri': '/Foo/BaR2/qux', } test['params'] = duo_client.client.normalize_params(test['params']) self.assertEqual(duo_client.client.canonicalize(sig_version=1, date=None, **test), 'POST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU') def test_v2(self): test = { 'date': 'Fri, 07 Dec 2012 17:18:00 -0000', 'host': 'foO.BAr52.cOm', 'method': 'PoSt', 'params': {u'\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170': [u'\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0'], u'\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813': [u'\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30'], u'\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042': [u'\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3'], u'\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934': [u'\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU']}, 'uri': '/Foo/BaR2/qux', } test['params'] = duo_client.client.normalize_params(test['params']) self.assertEqual(duo_client.client.canonicalize(sig_version=2, **test), 'Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU') def test_v4_with_json(self): hashed_body = hashlib.sha512(JSON_STRING.encode('utf-8')).hexdigest() expected = ( 'Tue, 04 Jul 2017 14:12:00\n' 'POST\n' 'foo.bar52.com\n' '/Foo/BaR2/qux\n\n' + hashed_body) params = {} actual = duo_client.client.canonicalize( 'POST', 'foO.BaR52.cOm', '/Foo/BaR2/qux', params, 'Tue, 04 Jul 2017 14:12:00', sig_version=4, body=JSON_STRING) self.assertEqual(actual, expected) def test_v5_with_json(self): hashed_body = hashlib.sha512(JSON_STRING.encode('utf-8')).hexdigest() headers = {"X-Duo-Header-1": "header_value_1"} expected = ( 'Tue, 17 Nov 2020 14:12:00\n' 'POST\n' 'foo.bar52.com\n' '/Foo/BaR2/qux\n\n' + hashed_body +'\n630b4bfe7e9abd03da2eee8f0a5d4e60a254ec880a839bcc2223bb5b9443e8ef24d58f0' '254f1f5934bf8c017ebd0fd5b1acf86766bdbe74185e712a4092df3ed') params = {} body = duo_client.client.Client.canon_json(JSON_BODY) actual = duo_client.client.canonicalize( 'POST', 'foO.BaR52.cOm', '/Foo/BaR2/qux', params, 'Tue, 17 Nov 2020 14:12:00', sig_version=5, body=body, additional_headers=headers) self.assertEqual(actual, expected) def test_invalid_signature_version_raises(self): params = duo_client.client.Client.canon_json(JSON_BODY) with self.assertRaises(ValueError) as e: duo_client.client.canonicalize( 'POST', 'foO.BaR52.cOm', '/Foo/BaR2/qux', params, 'Tue, 04 Jul 2017 14:12:00', sig_version=999) self.assertEqual( e.exception.args[0], "Unknown signature version: {}".format(999)) def test_signature_v5_lowers_and_then_sorts_headers(self): hashed_body = hashlib.sha512(JSON_STRING.encode('utf-8')).hexdigest() headers = { "x-duo-A": "header_value_1", "X-Duo-B": "header_value_2" } expected = ( 'Tue, 17 Nov 2020 14:12:00\n' 'POST\n' 'foo.bar52.com\n' '/Foo/BaR2/qux\n\n' + hashed_body +'\n60be11a30e0756f2ee2afdce1db849b987dcf86c1133394b' 'd7bbbc9877920330c4d78aceacbb377ab8cbd9a8efe6a410fed4047376635ac71226ab46ca10d2b1') params = {} body = duo_client.client.Client.canon_json(JSON_BODY) actual = duo_client.client.canonicalize( 'POST', 'foO.BaR52.cOm', '/Foo/BaR2/qux', params, 'Tue, 17 Nov 2020 14:12:00', sig_version=5, body=body, additional_headers=headers) self.assertEqual(actual, expected) class TestNormalizePageArgs(unittest.TestCase): def setUp(self): self.client = duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com') def test_normalize_page_args(self): tests = [ ( {}, (None, '0') ), ( {'offset': 9001}, (None, '9001'), ), ( {'limit': 2}, ('2', '0'), ), ( {'limit': '3'}, ('3', '0'), ), ( {'limit': 5, 'offset': 9002}, ('5', '9002') ) ] for (input, expected) in tests: output = self.client.normalize_paging_args(**input) self.assertEqual(output, expected) class TestSign(unittest.TestCase): """ Tests for proper signature creation for a request. """ def test_hmac_sha1(self): test = { 'date': 'Fri, 07 Dec 2012 17:18:00 -0000', 'host': 'foO.BAr52.cOm', 'method': 'PoSt', 'params': {u'\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170': [u'\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0'], u'\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813': [u'\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30'], u'\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042': [u'\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3'], u'\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934': [u'\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU']}, 'uri': '/Foo/BaR2/qux', } test['params'] = duo_client.client.normalize_params(test['params']) ikey = 'test_ikey' actual = duo_client.client.sign( sig_version=2, ikey=ikey, skey='gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT', digestmod=hashlib.sha1, **test ) expected = 'f01811cbbf9561623ab45b893096267fd46a5178' expected = ikey + ':' + expected if isinstance(expected, str): expected = expected.encode('utf-8') expected = base64.b64encode(expected).strip() if not isinstance(expected, str): expected = expected.decode('utf-8') expected = 'Basic ' + expected self.assertEqual(actual, expected) def test_hmac_sha512(self): test = { 'date': 'Fri, 07 Dec 2012 17:18:00 -0000', 'host': 'foO.BAr52.cOm', 'method': 'PoSt', 'params': {u'\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170': [u'\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0'], u'\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813': [u'\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30'], u'\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042': [u'\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3'], u'\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934': [u'\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU']}, 'uri': '/Foo/BaR2/qux', } test['params'] = duo_client.client.normalize_params(test['params']) ikey = 'test_ikey' actual = duo_client.client.sign( sig_version=2, ikey=ikey, skey='gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT', **test ) expected = '0508065035a03b2a1de2f453e629e791d180329e157f65df6b3e0f08299d4321e1c5c7a7c7ee6b9e5fc80d1fb6fbf3ad5eb7c44dd3b3985a02c37aca53ec3698' expected = ikey + ':' + expected if isinstance(expected, str): expected = expected.encode('utf-8') expected = base64.b64encode(expected).strip() if not isinstance(expected, str): expected = expected.decode('utf-8') expected = 'Basic ' + expected self.assertEqual(actual, expected) class TestRequest(unittest.TestCase): """ Tests for the request created by api_call and json_api_call. """ # usful args for testing args_in = { 'foo':['bar'], 'baz':['qux', 'quux=quuux', 'foobar=foobar&barbaz=barbaz']} args_out = dict( (key, [v for v in val]) for (key, val) in list(args_in.items())) def setUp(self): self.client = duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com') # monkeypatch client's _connect() self.client._connect = lambda: util.MockHTTPConnection() def test_api_call_get_no_params(self): (response, dummy) = self.client.api_call('GET', '/foo/bar', {}) self.assertEqual(response.method, 'GET') self.assertEqual(response.uri, '/foo/bar?') def test_api_call_post_no_params(self): (response, dummy) = self.client.api_call('POST', '/foo/bar', {}) self.assertEqual(response.method, 'POST') self.assertEqual(response.uri, '/foo/bar') self.assertEqual(json.loads(response.body), {}) def test_api_call_get_params(self): (response, dummy) = self.client.api_call( 'GET', '/foo/bar', self.args_in) self.assertEqual(response.method, 'GET') (uri, args) = response.uri.split('?') self.assertEqual(uri, '/foo/bar') self.assertEqual(util.params_to_dict(args), self.args_out) def test_api_call_post_params(self): (response, dummy) = self.client.api_call( 'POST', '/foo/bar', self.args_in) self.assertEqual(response.method, 'POST') self.assertEqual(response.uri, '/foo/bar') self.assertEqual(json.loads(response.body), self.args_out) def test_json_api_call_get_no_params(self): response = self.client.json_api_call('GET', '/foo/bar', {}) self.assertEqual(response['method'], 'GET') self.assertEqual(response['uri'], '/foo/bar?') self.assertEqual(response['body'], None) def test_json_api_call_post_no_params(self): response = self.client.json_api_call('POST', '/foo/bar', {}) self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/foo/bar') self.assertEqual(json.loads(response['body']), {}) def test_json_api_call_get_params(self): response = self.client.json_api_call( 'GET', '/foo/bar', self.args_in) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/foo/bar') self.assertEqual(util.params_to_dict(args), self.args_out) def test_json_api_call_post_params(self): response = self.client.json_api_call( 'POST', '/foo/bar', self.args_in) self.assertEqual(response['method'], 'POST') self.assertEqual(response['uri'], '/foo/bar') self.assertEqual(json.loads(response['body']), self.args_out) class TestPaging(unittest.TestCase): def setUp(self): self.client = util.CountingClient( 'test_ikey', 'test_akey', 'example.com', paging_limit=100) self.objects = [util.MockJsonObject() for i in range(1000)] self.client._connect = lambda: util.MockPagingHTTPConnection(self.objects) def test_get_objects_paging(self): response = self.client.json_paging_api_call( 'GET', '/admin/v1/objects', {}) self.assertEqual(len(self.objects), len(list(response))) self.assertEqual(10, self.client.counter) def test_get_no_objects_paging(self): self.objects = [] self.client._connect = lambda: util.MockPagingHTTPConnection(self.objects) response = self.client.json_paging_api_call( 'GET', '/admin/v1/objects', {}) self.assertEqual(len(self.objects), len(list(response))) self.assertEqual(1, self.client.counter) def test_get_objects_paging_limit(self): response = self.client.json_paging_api_call( 'GET', '/admin/v1/objects', {'limit':'250'}) self.assertEqual(len(self.objects), len(list(response))) self.assertEqual(4, self.client.counter) def test_get_all_objects(self): response = self.client.json_paging_api_call( 'GET', '/admin/v1/objects', {'limit':'1000'}) expected = [obj.to_json() for obj in self.objects] self.assertListEqual(expected, list(response)) self.assertEqual(1, self.client.counter) class TestAlternatePaging(unittest.TestCase): def setUp(self): self.client = util.CountingClient( 'test_ikey', 'test_akey', 'example.com', paging_limit=100) self.objects = [util.MockJsonObject() for i in range(1000)] self.client._connect = lambda: util.MockAlternatePagingHTTPConnection(self.objects) def test_get_objects_paging(self): response = self.client.json_cursor_api_call( 'GET', '/admin/v1/objects', {}, lambda response: response['data'] ) self.assertEqual(len(self.objects), len(list(response))) self.assertEqual(10, self.client.counter) def test_get_no_objects_paging(self): self.objects = [] self.client._connect = lambda: util.MockAlternatePagingHTTPConnection(self.objects) response = self.client.json_cursor_api_call( 'GET', '/admin/v1/objects', {}, lambda response: response['data'] ) self.assertEqual(len(self.objects), len(list(response))) self.assertEqual(1, self.client.counter) def test_get_objects_paging_limit(self): response = self.client.json_cursor_api_call( 'GET', '/admin/v1/objects', {'limit':'250'}, lambda response: response['data'] ) self.assertEqual(len(self.objects), len(list(response))) self.assertEqual(4, self.client.counter) def test_get_all_objects(self): response = self.client.json_cursor_api_call( 'GET', '/admin/v1/objects', {'limit':'1000'}, lambda response: response['data'] ) expected = [obj.to_json() for obj in self.objects] self.assertListEqual(expected, list(response)) self.assertEqual(1, self.client.counter) class TestRequestsV4(unittest.TestCase): # usful args for testing GETs args_in = { 'foo':['bar'], 'baz':['qux', 'quux=quuux', 'foobar=foobar&barbaz=barbaz']} args_out = dict( (key, [v for v in val]) for (key, val) in list(args_in.items())) def setUp(self): self.client = duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com', sig_timezone='America/Detroit', digestmod=hashlib.sha512, sig_version=4) # monkeypatch client's _connect() self.client._connect = lambda: util.MockHTTPConnection() def test_get_no_params(self): (response, dummy) = self.client.api_call('GET', '/foo/bar', {}) self.assertEqual(response.method, 'GET') self.assertEqual(response.uri, '/foo/bar?') self.assertIn('Authorization', response.headers) def test_get_params(self): (response, dummy) = self.client.api_call( 'GET', '/foo/bar', self.args_in) self.assertEqual(response.method, 'GET') (uri, args) = response.uri.split('?') self.assertEqual(uri, '/foo/bar') self.assertEqual(util.params_to_dict(args), self.args_out) self.assertIn('Authorization', response.headers) def test_json_api_call_get_no_params(self): response = self.client.json_api_call('GET', '/foo/bar', {}) self.assertEqual(response['method'], 'GET') self.assertEqual(response['uri'], '/foo/bar?') self.assertEqual(response['body'], None) self.assertIn('Authorization', response['headers']) def test_json_api_call_get_params(self): response = self.client.json_api_call( 'GET', '/foo/bar', self.args_in) self.assertEqual(response['method'], 'GET') (uri, args) = response['uri'].split('?') self.assertEqual(uri, '/foo/bar') self.assertEqual(util.params_to_dict(args), self.args_out) self.assertIn('Authorization', response['headers']) def test_json_post(self): (response, dummy) = self.client.api_call('POST', '/foo/bar', JSON_BODY) self.assertEqual(response.method, 'POST') self.assertEqual(response.uri, '/foo/bar') self.assertEqual(response.body, JSON_STRING) self.assertIn('Content-type', response.headers) self.assertEqual(response.headers['Content-type'], 'application/json') self.assertIn('Authorization', response.headers) def test_json_fails_with_bad_args(self): with self.assertRaises(ValueError) as e: (response, dummy) = self.client.api_call('POST', '/foo/bar', '') self.assertEqual(e.exception.args[0], "JSON request must be an object.") def test_json_put(self): (response, dummy) = self.client.api_call('PUT', '/foo/bar', JSON_BODY) self.assertEqual(response.method, 'PUT') self.assertEqual(response.uri, '/foo/bar') self.assertEqual(response.body, JSON_STRING) self.assertIn('Content-type', response.headers) self.assertEqual(response.headers['Content-type'], 'application/json') self.assertIn('Authorization', response.headers) class TestParseJsonResponse(unittest.TestCase): APIResponse = collections.namedtuple('APIResponse', 'status reason') def setUp(self): self.client = duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com', sig_timezone='America/Detroit', sig_version=2) def test_good_response(self): api_res = self.APIResponse(200, '') expected_data = { 'foo': 'bar' } res_body = { 'response': expected_data, 'stat': 'OK' } data = self.client.parse_json_response(api_res, json.dumps(res_body)) self.assertEqual(data, expected_data) def test_response_contains_invalid_json(self): api_res = self.APIResponse(200, 'Fake reason') response = 'Bad JSON' with self.assertRaises(RuntimeError) as e: self.client.parse_json_response(api_res, response) self.assertEqual(e.exception.status, api_res.status) self.assertEqual(e.exception.reason, api_res.reason) self.assertEqual(e.exception.data, response) def test_response_stat_isnot_OK(self): api_res = self.APIResponse(200, 'Fake reason') res_body = { 'response': { 'foo': 'bar' }, 'stat': 'FAIL' } with self.assertRaises(RuntimeError) as e: self.client.parse_json_response(api_res, json.dumps(res_body)) self.assertEqual(e.exception.status, api_res.status) self.assertEqual(e.exception.reason, api_res.reason) self.assertEqual(e.exception.data, res_body) def test_response_is_http_error(self): for code in range(201, 600): api_res = self.APIResponse(code, 'fake reason') res_body = { 'response': 'some message', 'stat': 'OK' } with self.assertRaises(RuntimeError) as e: self.client.parse_json_response(api_res, json.dumps(res_body)) self.assertEqual(e.exception.status, api_res.status) self.assertEqual(e.exception.reason, api_res.reason) self.assertEqual(e.exception.data, res_body) class TestParseJsonResponseAndMetadata(unittest.TestCase): APIResponse = collections.namedtuple('APIResponse', 'status reason') def setUp(self): self.client = duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com', sig_timezone='America/Detroit', sig_version=2) def test_good_response(self): api_res = self.APIResponse(200, '') expected_data = { 'foo': 'bar' } res_body = { 'response': expected_data, 'stat': 'OK' } data, metadata = self.client.parse_json_response_and_metadata(api_res, json.dumps(res_body)) self.assertEqual(data, expected_data) self.assertEqual(metadata, {}) def test_response_contains_invalid_json(self): api_res = self.APIResponse(200, 'Fake reason') response = 'Bad JSON' with self.assertRaises(RuntimeError) as e: self.client.parse_json_response_and_metadata(api_res, response) self.assertEqual(e.exception.status, api_res.status) self.assertEqual(e.exception.reason, api_res.reason) self.assertEqual(e.exception.data, response) def test_response_is_a_string(self): """ Some API requests return just a string in their response. We want to make sure we handle those correctly """ api_res = self.APIResponse(200, 'Fake reason') response = { 'response': "just a string", 'stat': 'OK' } try: self.client.parse_json_response_and_metadata(api_res, json.dumps(response)) except Exception: self.fail("parsing raised exception for string response") def test_response_is_a_list(self): """ Some API requests return just a list in their response. with metadata included at the top level. We want to make sure we handle those correctly """ api_res = self.APIResponse(200, "Fake reason") expected_metadata = { "offset": 4 } expected_response = ["multiple", "elements"] response = { "response": expected_response, "metadata": expected_metadata, "stat": "OK" } response, metadata = self.client.parse_json_response_and_metadata(api_res, json.dumps(response)) self.assertEqual(metadata, expected_metadata) def test_response_stat_isnot_OK(self): api_res = self.APIResponse(200, 'Fake reason') res_body = { 'response': { 'foo': 'bar' }, 'stat': 'FAIL' } with self.assertRaises(RuntimeError) as e: self.client.parse_json_response_and_metadata(api_res, json.dumps(res_body)) self.assertEqual(e.exception.status, api_res.status) self.assertEqual(e.exception.reason, api_res.reason) self.assertEqual(e.exception.data, res_body) def test_response_is_http_error(self): for code in range(201, 600): api_res = self.APIResponse(code, 'fake reason') res_body = { 'response': 'some message', 'stat': 'OK' } with self.assertRaises(RuntimeError) as e: self.client.parse_json_response_and_metadata(api_res, json.dumps(res_body)) self.assertEqual(e.exception.status, api_res.status) self.assertEqual(e.exception.reason, api_res.reason) self.assertEqual(e.exception.data, res_body) @mock.patch('duo_client.client.sleep') class TestRetryRequests(unittest.TestCase): def setUp(self): self.client = duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com', ) def test_non_limited_reponse(self, mock_sleep): # monkeypatch client's _connect() mock_connection = util.MockMultipleRequestHTTPConnection( [200]) self.client._connect = lambda: mock_connection (response, dummy) = self.client.api_call('GET', '/foo/bar', {}) mock_sleep.assert_not_called() self.assertEqual(response.status, 200) self.assertEqual(mock_connection.requests, 1) @mock.patch('duo_client.client.random') def test_single_limited_response(self, mock_random, mock_sleep): mock_random.uniform.return_value = 0.123 # monkeypatch client's _connect() mock_connection = util.MockMultipleRequestHTTPConnection( [429, 200]) self.client._connect = lambda: mock_connection (response, dummy) = self.client.api_call('GET', '/foo/bar', {}) mock_sleep.assert_called_once_with(1.123) mock_random.uniform.assert_called_once() self.assertEqual(response.status, 200) self.assertEqual(mock_connection.requests, 2) @mock.patch('duo_client.client.random') def test_all_limited_responses(self, mock_random, mock_sleep): mock_random.uniform.return_value = 0.123 # monkeypatch client's _connect() mock_connection = util.MockMultipleRequestHTTPConnection( [429, 429, 429, 429, 429, 429, 429]) self.client._connect = lambda: mock_connection (response, data) = self.client.api_call('GET', '/foo/bar', {}) expected_sleep_calls = ( mock.call(1.123), mock.call(2.123), mock.call(4.123), mock.call(8.123), mock.call(16.123), mock.call(32.123), ) mock_sleep.assert_has_calls(expected_sleep_calls) expected_random_calls = ( mock.call(0, 1), mock.call(0, 1), mock.call(0, 1), mock.call(0, 1), mock.call(0, 1), mock.call(0, 1), ) mock_random.uniform.assert_has_calls(expected_random_calls) self.assertEqual(response.status, 429) self.assertEqual(mock_connection.requests, 7) class TestInstantiate(unittest.TestCase): def test_sig_version_3_raises_exception(self): with self.assertRaises(ValueError): duo_client.client.Client( 'test_ikey', 'test_akey', 'example.com', sig_timezone='America/Detroit', sig_version=3) if __name__ == '__main__': unittest.main() duo_client_python-5.4.0/tests/test_https_wrapper.py000066400000000000000000000037621475537715400227550ustar00rootroot00000000000000from duo_client.https_wrapper import CertValidatingHTTPSConnection import unittest from unittest import mock import ssl class TestSSLContextCreation(unittest.TestCase): """ Test that the SSL context used to wrap sockets is configured correctly """ def test_no_ca_certs(self): conn = CertValidatingHTTPSConnection('api-fakehost.duosecurity.com') self.assertEqual(conn.default_ssl_context.verify_mode, ssl.CERT_NONE) # noqa: DUO122, testing insecure context @mock.patch('ssl.SSLContext.load_verify_locations') def test_with_ca_certs(self, mock_load): mock_load.return_value = None conn = CertValidatingHTTPSConnection('api-fakehost.duosecurity.com', ca_certs='cafilepath') self.assertEqual(conn.default_ssl_context.verify_mode, ssl.CERT_REQUIRED) mock_load.assert_called_with(cafile='cafilepath') @mock.patch('ssl.SSLContext.load_cert_chain') def test_with_certfile(self, mock_load): mock_load.return_value = None CertValidatingHTTPSConnection('api-fakehost.duosecurity.com', cert_file='certfilepath') mock_load.assert_called_with('certfilepath', None) def test_ssl2_ssl3_off(self): conn = CertValidatingHTTPSConnection('api-fakehost.duosecurity.com') self.assertEqual(conn.default_ssl_context.options & ssl.OP_NO_SSLv2, ssl.OP_NO_SSLv2) self.assertEqual(conn.default_ssl_context.options & ssl.OP_NO_SSLv3, ssl.OP_NO_SSLv3) @mock.patch('socket.socket.connect') def test_server_hostname(self, mock_connect): hostname = 'api-fakehost.duosecurity.com' conn = CertValidatingHTTPSConnection(hostname) conn.connect() self.assertEqual(conn.sock.server_hostname, hostname) @mock.patch('socket.socket.connect') def test_server_hostname_with_port(self, mock_connect): hostname = 'api-fakehost.duosecurity.com' conn = CertValidatingHTTPSConnection(f'{hostname}:443') conn.connect() self.assertEqual(conn.sock.server_hostname, hostname) duo_client_python-5.4.0/tests/util.py000066400000000000000000000124351475537715400177660ustar00rootroot00000000000000import json import collections import urllib.parse from json import JSONEncoder import duo_client class MockObjectJsonEncoder(json.JSONEncoder): def default(self, obj): return getattr(obj.__class__, "to_json")(obj) # put params in a dict to avoid inconsistent ordering def params_to_dict(param_str): param_dict = collections.defaultdict(list) for (key, val) in (param.split('=') for param in param_str.split('&')): param_dict[key].append(urllib.parse.unquote(val)) return param_dict class MockHTTPConnection(object): """ Mock HTTP(S) connection that returns a dummy JSON response. """ status = 200 # success! def __init__( self, data_response_should_be_list=False, data_response_from_get_authlog=False, data_response_from_get_dtm_events=False, data_response_from_get_items=False, ): # if a response object should be a list rather than # a dict, then set this flag to true self.data_response_should_be_list = data_response_should_be_list self.data_response_from_get_authlog = data_response_from_get_authlog self.data_response_from_get_dtm_events = data_response_from_get_dtm_events self.data_response_from_get_items = data_response_from_get_items def dummy(self): return self _connect = _disconnect = close = getresponse = dummy def read(self): response = self.__dict__ if self.data_response_should_be_list: response = [self.__dict__] if self.data_response_from_get_authlog: response['authlogs'] = [] if self.data_response_from_get_items: response['items'] = [] if self.data_response_from_get_dtm_events: response['events'] = [{"foo": "bar"}, {"bar": "foo"}] return json.dumps({"stat":"OK", "response":response}, cls=MockObjectJsonEncoder) def request(self, method, uri, body, headers): self.method = method self.uri = uri self.body = body self.headers = {} for k, v in headers.items(): if isinstance(k, bytes): k = k.decode('ascii') if isinstance(v, bytes): v = v.decode('ascii') self.headers[k] = v class MockJsonObject(object): def to_json(self): return {'id': id(self)} class CountingClient(duo_client.client.Client): def __init__(self, *args, **kwargs): super(CountingClient, self).__init__(*args, **kwargs) self.counter = 0 def _make_request(self, *args, **kwargs): self.counter += 1 return super(CountingClient, self)._make_request(*args, **kwargs) class MockPagingHTTPConnection(MockHTTPConnection): def __init__(self, objects=None): if objects is not None: self.objects = objects def dummy(self): return self _connect = _disconnect = close = getresponse = dummy def read(self): metadata = {} metadata['total_objects'] = len(self.objects) if self.offset + self.limit < len(self.objects): metadata['next_offset'] = self.offset + self.limit if self.offset > 0: metadata['prev_offset'] = max(self.offset-self.limit, 0) return json.dumps( {"stat":"OK", "response": self.objects[self.offset: self.offset+self.limit], "metadata": metadata}, cls=MockObjectJsonEncoder) def request(self, method, uri, body, headers): self.method = method self.uri = uri self.body = body self.headers = headers parsed = urllib.parse.urlparse(uri) params = urllib.parse.parse_qs(parsed.query) self.limit = int(params['limit'][0]) # offset is always present with list-based paging but cannot be # present on the initial request with cursor-based paging self.offset = int(params.get('offset', [0])[0]) class MockAlternatePagingHTTPConnection(MockPagingHTTPConnection): def read(self): metadata = {} metadata['total_objects'] = len(self.objects) if self.offset + self.limit < len(self.objects): metadata['next_offset'] = self.offset + self.limit if self.offset > 0: metadata['prev_offset'] = max(self.offset-self.limit, 0) return json.dumps( {"stat":"OK", "response": { "data" : self.objects[self.offset: self.offset+self.limit], "metadata": metadata }, }, cls=MockObjectJsonEncoder) class MockMultipleRequestHTTPConnection(MockHTTPConnection): def __init__(self, statuses): super(MockMultipleRequestHTTPConnection, self).__init__() self.statuses = statuses self.status_iterator = iter(statuses) self.requests = 0 self.status = None def read(self): response = {'foo': 'bar'} return json.dumps({"stat":"OK", "response":response}, cls=MockObjectJsonEncoder) def request(self, method, uri, body, headers): self.requests += 1 self.status = next(self.status_iterator) super(MockMultipleRequestHTTPConnection, self).request( method, uri, body, headers)