From c71094826f822418fe7a6a9cc49e438325472b93 Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Wed, 16 Sep 2020 14:16:48 -0400 Subject: [PATCH] Initial commit --- .flake8 | 3 + .gitignore | 13 + .travis.yml | 13 + LICENSE | 25 ++ README.md | 66 ++++ demo/README.md | 22 ++ demo/app.py | 93 ++++++ demo/requirements.txt | 1 + demo/static/images/logo.png | Bin 0 -> 33061 bytes demo/static/style.css | 56 ++++ demo/templates/login.html | 26 ++ demo/templates/success.html | 16 + duo_universal/__init__.py | 2 + duo_universal/ca_certs.pem | 239 ++++++++++++++ duo_universal/client.py | 309 ++++++++++++++++++ duo_universal/version.py | 1 + requirements.txt | 4 + setup.cfg | 3 + setup.py | 54 +++ tests/requirements.txt | 4 + tests/test_create_auth.py | 57 ++++ tests/test_create_jwt_args.py | 43 +++ tests/test_exchange_authorization_code.py | 298 +++++++++++++++++ tests/test_health_check.py | 85 +++++ tests/test_setup_client.py | 118 +++++++ tests/test_state.py | 68 ++++ tests/test_validate_create_auth_url_inputs.py | 65 ++++ 27 files changed, 1684 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 demo/README.md create mode 100644 demo/app.py create mode 100644 demo/requirements.txt create mode 100644 demo/static/images/logo.png create mode 100644 demo/static/style.css create mode 100644 demo/templates/login.html create mode 100644 demo/templates/success.html create mode 100644 duo_universal/__init__.py create mode 100644 duo_universal/ca_certs.pem create mode 100644 duo_universal/client.py create mode 100644 duo_universal/version.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/requirements.txt create mode 100644 tests/test_create_auth.py create mode 100644 tests/test_create_jwt_args.py create mode 100644 tests/test_exchange_authorization_code.py create mode 100644 tests/test_health_check.py create mode 100644 tests/test_setup_client.py create mode 100644 tests/test_state.py create mode 100644 tests/test_validate_create_auth_url_inputs.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..43b3645 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +# Ignore line too long errors +ignore = E501 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2932c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.egg-info* +*.pyc +*.pyo +*.swp +*~ +.env +.gitconfig +.idea +__pycache__ +build +dist +env/ +MANIFEST diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..360b544 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +python: + - '2.7' + - '3.5' + - '3.6' + - '3.7' + - '3.8' +install: + - pip install -r requirements.txt + - pip install -r tests/requirements.txt +script: + - flake8 + - nose2 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c8839d --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2020, Duo Security, Inc. +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2be6f8f --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Duo Universal Python SDK + +This SDK allows a web developer to quickly add Duo's interactive, self-service, two-factor authentication to any Python web login form. Both Python 2 and Python 3 are supported. + +What's here: +* `duo_universal` - The Python Duo SDK for interacting with the Duo Universal Prompt +* `demo` - An example web application with Duo integrated +* `tests` - Test cases + +## Getting Started +To use the SDK in your existing development environment, install it from pypi (https://pypi.org/project/duo_universal). +``` +pip install duo_universal +``` +Once it's installed, see our developer documentation at https://duo.com/docs/duoweb and `demo/app.py` in this repo for guidance on integrating Duo 2FA into your web application. + +## Contribute +To contribute, fork this repo and make a pull request with your changes when they're ready. + +If you're not already working from a dedicated development environment, it's recommended a virtual environment is used. Assuming a virtual environment named `env`, create and activate the environment: +``` +# Python 3 +python -m venv env +source env/bin/activate + +# Python 2 +virtualenv env +source env/bin/activate +``` + +Build and install the SDK form source: +``` +pip install -r requirements.txt +pip install . +``` + +## Tests +Install the test requirements: +``` +cd tests +pip install -r requirements.txt +``` +Then run tests from the `test` directory: +``` +# Run an individual test file +python .py + +# Run all tests with nose +nose2 + +# Run all tests with unittest +python -m unittest +``` + +## Lint +``` +flake8 +``` + +## Support + +Please report any bugs, feature requests, or issues to us directly at support@duosecurity.com. + +Thank you for using Duo! + +https://duo.com/ \ No newline at end of file diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..bc4e9c3 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,22 @@ +# Duo Universal Python SDK Demo + +A simple Python web application that serves a logon page integrated with Duo 2FA. + +## Setup +The following steps assume the SDK is already installed and that you're working in your preferred environment. See the top-level README for installation and environment setup instructions. + +Install the demo requirements: +``` +cd demo +pip install -r requirements.txt +``` + +Then, create a `Web SDK` application in the Duo Admin Panel. See https://duo.com/docs/protecting-applications for more details. +## Using the App + +1. Using the Client ID, Client Secret, and API Hostname for your `Web SDK` application, start the app. + ``` + DUO_CLIENT_ID= DUO_CLIENT_SECRET= DUO_API_HOST= python app.py + ``` +1. Navigate to http://localhost:8080. +1. Log in with the user you would like to enroll in Duo or with an already enrolled user (any password will work). \ No newline at end of file diff --git a/demo/app.py b/demo/app.py new file mode 100644 index 0000000..2d6e3b7 --- /dev/null +++ b/demo/app.py @@ -0,0 +1,93 @@ +import json +import os +import traceback + +from flask import Flask, request, redirect, session, render_template + +from duo_universal.client import Client, DuoException + +duo_client = Client( + client_id=os.environ.get("DUO_CLIENT_ID"), + client_secret=os.environ.get("DUO_CLIENT_SECRET"), + host=os.environ.get("DUO_API_HOST"), + redirect_uri="http://localhost:8080/duo-callback" + ) + +duo_failmode = os.environ.get("DUO_FAILMODE", "open") + +app = Flask(__name__) + + +@app.route("/", methods=['GET']) +def login(): + return render_template("login.html", message="This is a demo") + + +@app.route("/", methods=['POST']) +def login_post(): + """ + respond to HTTP POST with 2FA as long as health check passes + """ + try: + duo_client.health_check() + except DuoException: + traceback.print_exc() + if duo_failmode.upper() == "OPEN": + # If we're failing open errors in 2FA still allow for success + return render_template("success.html", + message="Login 'Successful', but 2FA Not Performed. Confirm Duo client/secret/host values are correct") + else: + # Otherwise the login fails and redirect user to the login page + return render_template("login.html", + message="2FA Unavailable. Confirm Duo client/secret/host values are correct") + + username = request.form.get('username') + + # Generate random string to act as a state for the exchange + state = duo_client.generate_state() + session['state'] = state + session['username'] = username + prompt_uri = duo_client.create_auth_url(username, state) + + # Redirect to prompt URI which will redirect to the client's redirect URI + # after 2FA + return redirect(prompt_uri) + + +# This route URL must match the redirect_uri passed to the duo client +@app.route("/duo-callback") +def duo_callback(): + if request.args.get('error'): + return "Got Error: {}".format(request.args) + + # Get state to verify consistency and originality + state = request.args.get('state') + + # Get authorization token to trade for 2FA + code = request.args.get('code') + + if 'state' in session and 'username' in session: + saved_state = session['state'] + username = session['username'] + else: + # For flask, if url used to get to login.html is not localhost, + # (ex: 127.0.0.1) then the sessions will be different + # and the localhost session does not have the state + return render_template("login.html", + message="No saved state please login again") + + # Ensure nonce matches from initial request + if state != saved_state: + return render_template("login.html", + message="Duo state does not match saved state") + + decoded_token = duo_client.exchange_authorization_code_for_2fa_result(code, username) + + # Exchange happened successfully so render success page + return render_template("success.html", + message=json.dumps(decoded_token, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + app.secret_key = os.urandom(32) + app.run(host="localhost", port=8080) diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..7deaf3b --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1 @@ +Flask==1.1.2 \ No newline at end of file diff --git a/demo/static/images/logo.png b/demo/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e7b5a78f7aa4cd97ef4addaab19d3635bbd23c8c GIT binary patch literal 33061 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
itQn> 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 + +
+ + +
+
{{ message }}
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + diff --git a/demo/templates/success.html b/demo/templates/success.html new file mode 100644 index 0000000..5e76a83 --- /dev/null +++ b/demo/templates/success.html @@ -0,0 +1,16 @@ + + +
+ + +
+

Auth Response:

+
+
+
{{ message }}
+
+
+ + diff --git a/duo_universal/__init__.py b/duo_universal/__init__.py new file mode 100644 index 0000000..5239952 --- /dev/null +++ b/duo_universal/__init__.py @@ -0,0 +1,2 @@ +from duo_universal.client import * +from duo_universal.version import __version__ diff --git a/duo_universal/ca_certs.pem b/duo_universal/ca_certs.pem new file mode 100644 index 0000000..9cce651 --- /dev/null +++ b/duo_universal/ca_certs.pem @@ -0,0 +1,239 @@ +subject= /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----- diff --git a/duo_universal/client.py b/duo_universal/client.py new file mode 100644 index 0000000..0d384e8 --- /dev/null +++ b/duo_universal/client.py @@ -0,0 +1,309 @@ +from six.moves.urllib.parse import urlencode +import time +import jwt +import requests +import json +import random +import string +import os +import platform +from duo_universal.version import __version__ + +CLIENT_ID_LENGTH = 20 +CLIENT_SECRET_LENGTH = 40 +JTI_LENGTH = 36 +MINIMUM_STATE_LENGTH = 22 +MAXIMUM_STATE_LENGTH = 1024 +STATE_LENGTH = 36 +SUCCESS_STATUS_CODE = 200 +FIVE_MINUTES_IN_SECONDS = 300 +# One minute in seconds +LEEWAY = 60 + +ERR_USERNAME = 'The username is invalid.' +ERR_NONCE = 'The nonce is invalid.' +ERR_CLIENT_ID = 'The Duo client id is invalid.' +ERR_CLIENT_SECRET = 'The Duo client secret is invalid.' +ERR_API_HOST = 'The Duo api host is invalid' +ERR_REDIRECT_URI = 'No redirect uri' +ERR_CODE = 'Missing authorization code' +ERR_UNKNOWN = 'An unknown error has occurred.' +ERR_GENERATE_LEN = 'Length needs to be at least 22' +ERR_STATE_LEN = ('State must be at least {MIN} characters long and no longer than {MAX} characters').format( + MIN=MINIMUM_STATE_LENGTH, + MAX=MAXIMUM_STATE_LENGTH +) + +API_HOST_URI_FORMAT = "https://{}" +OAUTH_V1_HEALTH_CHECK_ENDPOINT = "https://{}/oauth/v1/health_check" +OAUTH_V1_AUTHORIZE_ENDPOINT = "https://{}/oauth/v1/authorize" +OAUTH_V1_TOKEN_ENDPOINT = "https://{}/oauth/v1/token" +DEFAULT_CA_CERT_PATH = os.path.join(os.path.dirname(__file__), 'ca_certs.pem') + +CLIENT_ASSERT_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +class DuoException(Exception): + pass + + +class Client: + + def _generate_rand_alphanumeric(self, length): + """ + Generates random string + Arguments: + + length -- Desired length of random string + + Returns: + + Randomly generated alphanumeric string + + Raises: + + ValueError if length is too short + """ + if length < min(MINIMUM_STATE_LENGTH, JTI_LENGTH): + raise ValueError(ERR_GENERATE_LEN) + generator = random.SystemRandom() + characters = string.ascii_letters + string.digits + return ''.join(generator.choice(characters) for i in range(length)) + + def _validate_init_config(self, client_id, client_secret, + api_host, redirect_uri): + """ + Verifies __init__ parameters + + Arguments: + + client_id -- Client ID for the application in Duo + client_secret -- Client secret for the application in Duo + host -- Duo api host + redirect_uri -- Uri to redirect to after a successful auth + + Raises: + + DuoException errors for invalid parameters + """ + if not client_id or len(client_id) != CLIENT_ID_LENGTH: + raise DuoException(ERR_CLIENT_ID) + if not client_secret or len(client_secret) != CLIENT_SECRET_LENGTH: + raise DuoException(ERR_CLIENT_SECRET) + if not api_host: + raise DuoException(ERR_API_HOST) + if not redirect_uri: + raise DuoException(ERR_REDIRECT_URI) + + def _validate_create_auth_url_inputs(self, username, state): + if not state or not (MINIMUM_STATE_LENGTH <= len(state) <= MAXIMUM_STATE_LENGTH): + raise DuoException(ERR_STATE_LEN) + if not username: + raise DuoException(ERR_USERNAME) + + def _create_jwt_args(self, endpoint): + jwt_args = { + 'iss': self._client_id, + 'sub': self._client_id, + 'aud': endpoint, + 'exp': time.time() + FIVE_MINUTES_IN_SECONDS, + 'jti': self._generate_rand_alphanumeric(JTI_LENGTH) + } + + return jwt_args + + def __init__(self, client_id, client_secret, host, + redirect_uri, duo_certs=DEFAULT_CA_CERT_PATH): + """ + Initializes instance of Client class + + Arguments: + + client_id -- Client ID for the application in Duo + client_secret -- Client secret for the application in Duo + host -- Duo api host + redirect_uri -- Uri to redirect to after a successful auth + """ + + self._validate_init_config(client_id, + client_secret, + host, + redirect_uri) + + self._client_id = client_id + self._client_secret = client_secret + self._api_host = host + self._redirect_uri = redirect_uri + + # If duo_certs is None set it to the DEFAULT_CA_CERT_PATH + # so that we make sure we are pinning certs + if duo_certs is not None: + if duo_certs == "DISABLE": + self._duo_certs = False + else: + self._duo_certs = duo_certs + else: + self._duo_certs = DEFAULT_CA_CERT_PATH + + def generate_state(self): + """ + Return a random string of 36 characters + """ + return self._generate_rand_alphanumeric(STATE_LENGTH) + + def health_check(self): + """ + Checks whether Duo is available. + + Returns: + + {'response': {'timestamp': }, 'stat': 'OK'} + + Raises: + + DuoException on error for invalid credentials + or problem connecting to Duo + """ + + health_check_endpoint = OAUTH_V1_HEALTH_CHECK_ENDPOINT.format(self._api_host) + + jwt_args = self._create_jwt_args(health_check_endpoint) + + all_args = { + 'client_assertion': jwt.encode(jwt_args, + self._client_secret, + algorithm='HS512'), + 'client_id': self._client_id + } + try: + response = requests.post(health_check_endpoint, + data=all_args, + verify=self._duo_certs) + res = json.loads(response.content) + if res['stat'] != 'OK': + raise DuoException(res) + + except Exception as e: + raise DuoException(e) + + return res + + def create_auth_url(self, username, state): + """Generate uri to Duo's prompt + + Arguments: + + username -- username trying to authenticate with Duo + state -- Randomly generated character string of at least 22 + chars returned to the integration by Duo after 2FA + + Returns: + + Authorization uri to redirect to for the Duo prompt + """ + + self._validate_create_auth_url_inputs(username, state) + + authorize_endpoint = OAUTH_V1_AUTHORIZE_ENDPOINT.format(self._api_host) + + jwt_args = { + 'scope': 'openid', + 'redirect_uri': self._redirect_uri, + 'client_id': self._client_id, + 'iss': self._client_id, + 'aud': API_HOST_URI_FORMAT.format(self._api_host), + 'exp': time.time() + FIVE_MINUTES_IN_SECONDS, + 'state': state, + 'response_type': 'code', + 'duo_uname': username, + } + + request_jwt = jwt.encode(jwt_args, + self._client_secret, + algorithm='HS512') + all_args = { + 'response_type': 'code', + 'client_id': self._client_id, + 'request': request_jwt, + } + + query_string = urlencode(all_args) + authorization_uri = "{}?{}".format(authorize_endpoint, query_string) + return authorization_uri + + def exchange_authorization_code_for_2fa_result(self, code, username, nonce=None): + """ + Exchange the code for a token with Duo to determine + if the auth was successful. + + Argument: + + code -- Authentication session transaction id + returned by Duo + username -- Name of the user authenticating with Duo + nonce -- Random 36B string used to associate + a session with an ID token + + Return: + + A token with meta-data about the auth + + Raises: + + DuoException on error for invalid codes, invalid credentials, + or problems connecting to Duo + """ + if not code: + raise DuoException(ERR_CODE) + + token_endpoint = OAUTH_V1_TOKEN_ENDPOINT.format(self._api_host) + jwt_args = self._create_jwt_args(token_endpoint) + + all_args = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': self._redirect_uri, + 'client_id': self._client_id, + 'client_assertion_type': CLIENT_ASSERT_TYPE, + 'client_assertion': jwt.encode(jwt_args, + self._client_secret, + algorithm='HS512') + } + try: + user_agent = ("duo_universal_python/{version} " + "python/{python_version} {os_name}").format(version=__version__, + python_version=platform.python_version(), + os_name=platform.platform()) + response = requests.post(token_endpoint, + params=all_args, + headers={"user-agent": + user_agent}, + verify=self._duo_certs) + except Exception as e: + raise DuoException(e) + + if response.status_code != SUCCESS_STATUS_CODE: + error_message = json.loads(response.content) + raise DuoException(error_message) + + try: + decoded_token = jwt.decode( + response.json()['id_token'], + self._client_secret, + audience=self._client_id, + issuer=OAUTH_V1_TOKEN_ENDPOINT.format(self._api_host), + leeway=LEEWAY, + algorithms=["HS512"], + options={'require_exp': True, + 'require_iat': True, + 'verify_iat': True}, + ) + except Exception as e: + raise DuoException(e) + + if ('preferred_username' not in decoded_token or not decoded_token['preferred_username'] == username): + raise DuoException(ERR_USERNAME) + if nonce and ('nonce' not in decoded_token or not decoded_token['nonce'] == nonce): + raise DuoException(ERR_NONCE) + + return decoded_token diff --git a/duo_universal/version.py b/duo_universal/version.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/duo_universal/version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..86e0967 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +cryptography==2.7 +PyJWT==1.7.1 +pyOpenSSL==19.0.0 +requests==2.22.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e601709 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[bdist_wheel] +# The code is written to work on both Python 2 and Python 3. +universal=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4abb356 --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ +from setuptools import setup +import os.path +import codecs + +def read(rel_path): + here = os.path.abspath(os.path.dirname(__file__)) + with codecs.open(os.path.join(here, rel_path), 'r') as fp: + return fp.read() + +def get_version(rel_path): + for line in read(rel_path).splitlines(): + if line.startswith('__version__'): + delim = '"' if '"' in line else "'" + return line.split(delim)[1] + else: + raise RuntimeError("Unable to find version string.") + +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__)), 'tests/requirements.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_universal', + version=get_version("duo_universal/version.py"), + packages=['duo_universal'], + package_data={'duo_universal': ['ca_certs.pem']}, + url='https://github.com/duosecurity/duo_universal_python', + license='BSD', + author='Duo Security, Inc.', + author_email='support@duosecurity.com', + description='Duo Web SDK for two-factor authentication', + long_description=long_description, + long_description_content_type='text/markdown', + classifiers=[ + 'Programming Language :: Python', + 'License :: OSI Approved :: BSD License' + ], + install_requires=install_requires, + tests_require=tests_require +) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..c5e0187 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +mock==3.0.5 +nose2==0.9.1 +flake8==3.7.9 +dlint==0.9.2 diff --git a/tests/test_create_auth.py b/tests/test_create_auth.py new file mode 100644 index 0000000..aad622d --- /dev/null +++ b/tests/test_create_auth.py @@ -0,0 +1,57 @@ +from six.moves.urllib.parse import urlencode +from duo_python_oidc import client +from mock import MagicMock, patch +import jwt +import unittest + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" +USERNAME = "user1" +STATE = "deadbeefdeadbeefdeadbeefdeadbeefdead" + +NONE = None + +EXPECTED_JWT_ARGS = { + 'scope': 'openid', + 'redirect_uri': REDIRECT_URI, + 'client_id': CLIENT_ID, + 'iss': CLIENT_ID, + 'aud': client.API_HOST_URI_FORMAT.format(HOST), + 'exp': 302, + 'state': STATE, + 'response_type': 'code', + 'duo_uname': USERNAME, +} + +EXPECTED_ALL_ARGS = { + 'response_type': 'code', + 'client_id': CLIENT_ID, + 'request': jwt.encode(EXPECTED_JWT_ARGS, CLIENT_SECRET, algorithm='HS512'), +} + + +class TestCreateAuthUrl(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + @patch('time.time', MagicMock(return_value=2)) + def test_encrypted_jwt(self): + """ + Test create_auth_url returns a valid authorization uri + """ + authorize_endpoint = \ + client.OAUTH_V1_AUTHORIZE_ENDPOINT.format(HOST) + encoded_all_args = urlencode(EXPECTED_ALL_ARGS) + + expected_authorization_uri = "{}?{}".format(authorize_endpoint, + encoded_all_args) + actual_authorization_uri = self.client.create_auth_url(USERNAME, STATE) + + self.assertEqual(expected_authorization_uri, actual_authorization_uri) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_create_jwt_args.py b/tests/test_create_jwt_args.py new file mode 100644 index 0000000..85ee494 --- /dev/null +++ b/tests/test_create_jwt_args.py @@ -0,0 +1,43 @@ +from mock import MagicMock, patch +from duo_python_oidc import client +import unittest + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" + +ERROR_TIMEOUT = "Connection to api-xxxxxxx.test.duosecurity.com timed out." +ERROR_NETWORK_CONNECTION_FAILED = "Failed to establish a new connection" + +EXPIRATION_TIME = 10 + client.FIVE_MINUTES_IN_SECONDS +RAND_ALPHANUMERIC_STR = "deadbeef" + +SUCCESS_JWT_ARGS = { + 'iss': CLIENT_ID, + 'sub': CLIENT_ID, + 'aud': client.OAUTH_V1_TOKEN_ENDPOINT, + 'exp': EXPIRATION_TIME, + 'jti': RAND_ALPHANUMERIC_STR + } + + +class TestCreateJwtArgs(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + @patch("time.time", MagicMock(return_value=10)) + def test_create_jwt_args_success(self): + """ + Test that _create_jwt_args creates proper jwt arguments + """ + self.client._generate_rand_alphanumeric = MagicMock( + return_value=RAND_ALPHANUMERIC_STR) + actual_jwt_args = self.client._create_jwt_args( + client.OAUTH_V1_TOKEN_ENDPOINT) + self.assertEqual(SUCCESS_JWT_ARGS, actual_jwt_args) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_exchange_authorization_code.py b/tests/test_exchange_authorization_code.py new file mode 100644 index 0000000..67fcf3a --- /dev/null +++ b/tests/test_exchange_authorization_code.py @@ -0,0 +1,298 @@ +from jwt.exceptions import InvalidSignatureError +from mock import MagicMock, patch +from duo_python_oidc import client +import json +import unittest +import requests +import jwt +import time + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +WRONG_CLIENT_ID = "DIXXXXXXXXXXXXXXXXXY" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +WRONG_CLIENT_SECRET = "wrongclientidwrongclientidwrongclientidw" +HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" +CODE = "deadbeefdeadbeefdeadbeefdeadbeef" +WRONG_CODE = "deadbeefdeadbeefdeadbeefdeadbeee" +USERNAME = "username" +WRONG_USERNAME = "wrong_username" +NONCE = "abcdefghijklmnopqrstuvwxyzabcdef" +WRONG_NONCE = "bbcceeggiikkmmooqqssuuwwyyaaccee" + +WRONG_CERT_ERROR = 'certificate verify failed' +ERROR_TIMEOUT = "Connection to api-xxxxxxx.test.duosecurity.com timed out." +ERROR_NETWORK_CONNECTION_FAILED = "Failed to establish a new connection" + +REQUESTS_POST_ERROR = 400 +REQUESTS_POST_SUCCESS = 200 +ERROR_WRONG_CODE = {'error': 'invalid_grant', + 'error_description': 'The provided authorization grant or ' + 'refresh token is invalid, expired, ' + 'revoked, does not match ' + 'the redirection URI.'} +ERROR_WRONG_CLIENT_ID = {'error': 'invalid_client', + 'error_description': 'Invalid Client assertion: ' + 'The `iss` claim must match ' + 'the supplied client_id'} +NONE = None + + +class TestExchangeAuthCodeInputs(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + self.client_wrong_client_id = client.Client(WRONG_CLIENT_ID, CLIENT_SECRET, + HOST, REDIRECT_URI) + self.jwt_decode = {'auth_result': + {'result': 'allow', + 'status': 'allow', + 'status_msg': 'Login Successful'}, + 'aud': CLIENT_ID, + 'auth_time': time.time(), + 'exp': time.time() + client.FIVE_MINUTES_IN_SECONDS, + 'iat': time.time() + 1, + 'iss': 'https://{}/oauth/v1/token'.format(HOST), + 'preferred_username': USERNAME} + + def test_no_code(self): + """ + Test that exchange_authorization_code_for_2fa_result + throws a DuoException if there is no code + """ + with self.assertRaises(client.DuoException) as e: + self.client.exchange_authorization_code_for_2fa_result(NONE, USERNAME) + self.assertEqual(e, client.ERR_CODE) + + @patch('requests.post', MagicMock( + side_effect=requests.Timeout(ERROR_TIMEOUT))) + def test_exchange_authorization_code_timeout_error(self): + """ + Test that exchange_authorization_code_for_2fa_result + throws DuoException if the request times out + """ + with self.assertRaises(client.DuoException) as e: + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + self.assertEqual(e, ERROR_TIMEOUT) + + @patch('requests.post', MagicMock( + side_effect=requests.ConnectionError( + ERROR_NETWORK_CONNECTION_FAILED))) + def test_exchange_authorization_code_duo_down_error(self): + """ + Test that exchange_authorization_code_for_2fa_result + throws DuoException if the network connection failed + """ + with self.assertRaises(client.DuoException) as e: + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + self.assertEqual(e, ERROR_NETWORK_CONNECTION_FAILED) + + @patch('requests.post') + @patch('json.loads') + def test_exchange_authorization_code_wrong_client_id(self, + mock_json_loads, + mock_post): + """ + Test that a wrong integration key throws a DuoException + """ + mock_post.return_value.status_code = REQUESTS_POST_ERROR + mock_json_loads.return_value = ERROR_WRONG_CLIENT_ID + + with self.assertRaises(client.DuoException) as e: + self.client_wrong_client_id.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + self.assertEqual(e['error'], 'invalid_grant') + + @patch('requests.post') + @patch('json.loads') + def test_exchange_authorization_code_wrong_code(self, + mock_json_loads, + mock_post): + """ + Test that a wrong code exchanged with Duo throws a DuoException + """ + mock_post.return_value.status_code = REQUESTS_POST_ERROR + mock_json_loads.return_value = ERROR_WRONG_CODE + + with self.assertRaises(client.DuoException) as e: + self.client.exchange_authorization_code_for_2fa_result(WRONG_CODE, USERNAME) + self.assertEqual(e['error'], 'invalid_client') + + @patch('requests.post') + def test_exchange_authorization_code_wrong_cert(self, requests_mock): + """ + Test that a wrong Duo Cert causes the client to throw a DuoException + """ + requests_mock.side_effect = requests.exceptions.SSLError(WRONG_CERT_ERROR) + with self.assertRaises(client.DuoException) as e: + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + self.assertEqual(e, WRONG_CERT_ERROR) + + @patch('requests.post') + @patch('jwt.decode') + def test_exchange_authorization_code_success(self, mock_jwt, mock_post): + """ + Test that a successful authorization code exchange + returns a successful jwt + """ + mock_post.return_value.status_code = REQUESTS_POST_SUCCESS + mock_jwt.return_value = self.jwt_decode + output = self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + self.assertEqual(output, self.jwt_decode) + + @patch('requests.post') + def test_exchange_authorization_nonce(self, mock_post): + """ + Test that a good nonce succeeds + """ + mock_post.return_value = self.generate_post_return_value('nonce', NONCE) + output = self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME, NONCE) + self.assertEqual(output, self.jwt_decode) + + @patch('requests.post') + def test_invalid_token_signature_failure(self, mock_post): + """ + Test that an altered token fails signature validation + """ + # Invalid token created by altering the payload of a valid token + mock_response = MockResponse(status_code=200, content={"id_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhYmNkZWYiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.kmo4YemI0g8l9fGV1z5Obec3oEQdeena21lFrDrID9O2NmPC-6Oh2InZ0Gd34EhqevZ5dqRf-nfYNAL6nDS33A"}) + mock_post.return_value = mock_response + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_invalid_signing_key_failure(self, mock_post): + """ + Test that a token signed with the wrong secret throws an error + """ + encoded_jwt = jwt.encode(self.jwt_decode, WRONG_CLIENT_SECRET, algorithm='HS512') + id_token = {"id_token": encoded_jwt} + mock_post.return_value = MockResponse(id_token) + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + self.assertEqual(e.message, "Signature verification failed") + + @patch('requests.post') + def test_exchange_authorization_wrong_preferred_username(self, mock_post): + """ + Test that a wrong preferred name throws an error + """ + mock_post.return_value = self.generate_post_return_value('preferred_username', WRONG_USERNAME) + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_wrong_aud(self, mock_post): + """ + Test that a wrong audience throws an error + """ + mock_post.return_value = self.generate_post_return_value('aud', WRONG_CLIENT_ID) + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_no_aud(self, mock_post): + """ + Test that no audience throws an error + """ + mock_post.return_value = self.pop_post_return_value('aud') + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_wrong_iss(self, mock_post): + """ + Test that a wrong issuer throws an error + """ + mock_post.return_value = self.generate_post_return_value('iss', HOST) + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_no_preferred_username(self, mock_post): + """ + Test that no preferred name throws an error + """ + mock_post.return_value = self.pop_post_return_value('preferred_username') + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_no_iat(self, mock_post): + """ + Test that no iat throws an error + """ + mock_post.return_value = self.pop_post_return_value('iat') + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_no_exp(self, mock_post): + """ + Test that no expiration throws an error + """ + mock_post.return_value = self.pop_post_return_value('exp') + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_past_exp(self, mock_post): + """ + Test that an expired auth throws an error + """ + mock_post.return_value = self.generate_post_return_value('exp', 1) + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME) + + @patch('requests.post') + def test_exchange_authorization_wrong_nonce(self, mock_post): + """ + Test that a wrong nonce throws an error + """ + mock_post.return_value = self.generate_post_return_value('nonce', WRONG_NONCE) + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME, NONCE) + + @patch('requests.post') + @patch('jwt.decode') + def test_exchange_authorization_no_nonce(self, mock_jwt, mock_post): + """ + Test that a no nonce when one is expected throws an error + """ + mock_post.return_value.status_code = REQUESTS_POST_SUCCESS + mock_jwt.return_value = self.jwt_decode + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, USERNAME, NONCE) + + @patch('requests.post') + @patch('jwt.decode') + def test_exchange_authorization_no_username(self, mock_jwt, mock_post): + """ + Test that a no username throws an error + """ + mock_post.return_value.status_code = REQUESTS_POST_SUCCESS + mock_jwt.return_value = self.jwt_decode + with self.assertRaises(client.DuoException): + self.client.exchange_authorization_code_for_2fa_result(CODE, None) + + def generate_post_return_value(self, key, value): + self.jwt_decode[key] = value + id_token = {"id_token": jwt.encode(self.jwt_decode, CLIENT_SECRET, algorithm='HS512')} + return MockResponse(id_token) + + def pop_post_return_value(self, key): + self.jwt_decode.pop(key) + id_token = {"id_token": jwt.encode(self.jwt_decode, CLIENT_SECRET, algorithm='HS512')} + return MockResponse(id_token) + + +class MockResponse: + def __init__(self, content, status_code=200): + self.status_code = status_code + self.content = content + + def json(self): + return self.content + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_health_check.py b/tests/test_health_check.py new file mode 100644 index 0000000..4e154e6 --- /dev/null +++ b/tests/test_health_check.py @@ -0,0 +1,85 @@ +from mock import MagicMock, patch +from duo_python_oidc import client +import requests +import unittest + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +WRONG_CLIENT_ID = "DIXXXXXXXXXXXXXXXXXY" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" + +WRONG_CERT_ERROR = 'certificate verify failed' +SUCCESS_CHECK = {'response': {'timestamp': 1573068322}, 'stat': 'OK'} +ERROR_TIMEOUT = "Connection to api-xxxxxxx.test.duosecurity.com timed out." +ERROR_NETWORK_CONNECTION_FAILED = "Failed to establish a new connection" +ERROR_WRONG_CLIENT_ID = {'message': 'invalid_client', + 'code': 40002, 'stat': 'FAIL', + 'message_detail': 'The provided client_assertion' + 'was invalid.', + 'timestamp': 1573053670} + + +class TestHealthCheck(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + self.client_wrong_client_id = client.Client(WRONG_CLIENT_ID, CLIENT_SECRET, + HOST, REDIRECT_URI) + + @patch('requests.post', MagicMock(side_effect=requests.Timeout( + ERROR_TIMEOUT))) + def test_health_check_timeout_error(self): + """ + Test health check failure due to a timeout + """ + with self.assertRaises(client.DuoException) as e: + self.client.health_check() + self.assertEqual(e, ERROR_TIMEOUT) + + @patch('requests.post', MagicMock(side_effect=requests.ConnectionError( + ERROR_NETWORK_CONNECTION_FAILED))) + def test_health_check_duo_down_error(self): + """ + Test health check failure due to a connection error, + either because it cannot reach Duo or the network is down + """ + with self.assertRaises(client.DuoException) as e: + self.client.health_check() + self.assertEqual(e, ERROR_NETWORK_CONNECTION_FAILED) + + @patch('requests.post') + @patch('json.loads') + def test_health_check_wrong_client_id(self, json_mock, requests_mock): + """ + Test health check failure due to a bad client_id + """ + requests_mock.return_value.content = ERROR_WRONG_CLIENT_ID + json_mock.return_value = ERROR_WRONG_CLIENT_ID + with self.assertRaises(client.DuoException): + self.client_wrong_client_id.health_check() + + @patch('requests.post') + def test_health_check_bad_cert(self, requests_mock): + """ + Test health check failure due to bad Duo Cert + """ + requests_mock.side_effect = requests.exceptions.SSLError(WRONG_CERT_ERROR) + with self.assertRaises(client.DuoException): + self.client.health_check() + self.assertEqual(e, WRONG_CERT_ERROR); + + @patch('requests.post') + @patch('json.loads') + def test_health_check_success(self, json_mock, requests_mock): + """ + Successful health check + """ + requests_mock.return_value.content = SUCCESS_CHECK + json_mock.return_value = SUCCESS_CHECK + result = self.client.health_check() + self.assertEqual(result['stat'], 'OK') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_setup_client.py b/tests/test_setup_client.py new file mode 100644 index 0000000..0a57873 --- /dev/null +++ b/tests/test_setup_client.py @@ -0,0 +1,118 @@ +from duo_python_oidc import client +import unittest + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +LONG_CLIENT_ID = "DIXXXXXXXXXXXXXXXXXXZ" +SHORT_CLIENT_ID = "DIXXXXXXXXXXXXXXXXX" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +SHORT_CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbee" +LONG_CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeeff" +HOST = "api-XXXXXXX.test.duosecurity.com" +WRONG_HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" +CA_CERT_NEW = "/path/to/cert/ca_cert_new.pem" +NONE = None + + +class TestCheckConf(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + def test_short_client_id(self): + """ + Test short client_id throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(SHORT_CLIENT_ID, CLIENT_SECRET, + HOST, REDIRECT_URI) + self.assertEqual(e, client.ERR_CLIENT_ID) + + def test_long_client_id(self): + """ + Test long client_id throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(LONG_CLIENT_ID, CLIENT_SECRET, + HOST, REDIRECT_URI) + self.assertEqual(e, client.ERR_CLIENT_ID) + + def test_no_client_id(self): + """ + Test no client_id throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(NONE, CLIENT_SECRET, + HOST, REDIRECT_URI) + self.assertEqual(e, client.ERR_CLIENT_ID) + + def test_short_client_secret(self): + """ + Test short client_secret throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(CLIENT_ID, SHORT_CLIENT_SECRET, + HOST, REDIRECT_URI) + self.assertEqual(e, client.ERR_CLIENT_SECRET) + + def test_long_client_secret(self): + """ + Test long client_secret throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(CLIENT_ID, LONG_CLIENT_SECRET, + HOST, REDIRECT_URI) + self.assertEqual(e, client.ERR_CLIENT_SECRET) + + def test_no_client_secret(self): + """ + Test no client_secret throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(CLIENT_ID, NONE, + HOST, REDIRECT_URI) + self.assertEqual(e, client.ERR_CLIENT_SECRET) + + def test_no_host(self): + """ + Test no host throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET, + NONE, REDIRECT_URI) + self.assertEqual(e, client.ERR_API_HOST) + + def test_no_redirect_uri(self): + """ + Test no redirect_uri throws DuoException + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET, + HOST, NONE) + self.assertEqual(e, client.ERR_REDIRECT_URI) + + def test_successful(self): + """ + Test successful _validate_init_config + """ + self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET, + HOST, REDIRECT_URI) + + def test_no_duo_cert(self): + self.assertEqual(self.client._duo_certs, client.DEFAULT_CA_CERT_PATH) + + def test_new_duo_cert(self): + new_cert_client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI, CA_CERT_NEW) + self.assertEqual(new_cert_client._duo_certs, CA_CERT_NEW) + + def test_none_duo_cert(self): + new_cert_client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI, None) + self.assertEqual(new_cert_client._duo_certs, client.DEFAULT_CA_CERT_PATH) + + def test_disable_duo_cert(self): + new_cert_client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI, "DISABLE") + self.assertFalse(new_cert_client._duo_certs) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..5b16946 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,68 @@ +from duo_python_oidc import client +import unittest + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +LONG_CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeeff" +HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" + +SHORT_STATE_LENGTH = client.MINIMUM_STATE_LENGTH - 1 +ZERO_LENGTH = 0 + + +class TestGenerateState(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + def test_generate_state_length(self): + """ + Test generate_state outputs a string + that has a length of client.STATE_LENGTH + """ + output = self.client.generate_state() + self.assertEqual(client.STATE_LENGTH, len(output)) + + def test_generate_state_random(self): + """ + Test that running generate_state twice gives two different outputs + """ + first_output = self.client.generate_state() + second_output = self.client.generate_state() + self.assertNotEqual(first_output, second_output) + + +class TestGenerateRandomAlphanumeric(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + def test_zero_length(self): + """ + Test zero length throws DuoException error + """ + with self.assertRaises(ValueError) as e: + self.client._generate_rand_alphanumeric(ZERO_LENGTH) + self.assertEqual(e, client.ERR_GENERATE_LEN) + + def test_short_length(self): + """ + Test short length throws DuoException error + """ + with self.assertRaises(ValueError) as e: + self.client._generate_rand_alphanumeric(SHORT_STATE_LENGTH) + self.assertEqual(e, client.ERR_GENERATE_LEN) + + def test_success(self): + """ + Test that _generate_rand_alphanumeric + returns string with length STATE_LENGTH + """ + generate = self.client._generate_rand_alphanumeric( + client.STATE_LENGTH) + self.assertEqual(client.STATE_LENGTH, len(generate)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_validate_create_auth_url_inputs.py b/tests/test_validate_create_auth_url_inputs.py new file mode 100644 index 0000000..11c7867 --- /dev/null +++ b/tests/test_validate_create_auth_url_inputs.py @@ -0,0 +1,65 @@ +from duo_python_oidc import client +import unittest + +CLIENT_ID = "DIXXXXXXXXXXXXXXXXXX" +CLIENT_SECRET = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +HOST = "api-XXXXXXX.test.duosecurity.com" +REDIRECT_URI = "https://www.example.com" +USERNAME = "user1" +STATE = "deadbeefdeadbeefdeadbeefdeadbeefdead" +SHORT_STATE = STATE[:client.MINIMUM_STATE_LENGTH - 1] +LONG_LENGTH = "a" * (client.MAXIMUM_STATE_LENGTH + 1) +NONE = None + + +class TestCreateAuthUrlInputs(unittest.TestCase): + + def setUp(self): + self.client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + def test_no_state(self): + """ + Test _validate_create_auth_url_inputs + throws a DuoException if the state is None + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_create_auth_url_inputs(USERNAME, NONE) + self.assertEqual(e, client.ERR_STATE) + + def test_short_state(self): + """ + Test _validate_create_auth_url_inputs + throws a DuoException if the state is too short + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_create_auth_url_inputs(USERNAME, SHORT_STATE) + self.assertEqual(e, client.ERR_STATE) + + def test_long_state(self): + """ + Test _validate_create_auth_url_inputs + throws a DuoException if the state is too long + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_create_auth_url_inputs(USERNAME, LONG_LENGTH) + self.assertEqual(e, client.ERR_STATE) + + def test_no_username(self): + """ + Test _validate_create_auth_url_inputs + throws a DuoException if the username is None + """ + with self.assertRaises(client.DuoException) as e: + self.client._validate_create_auth_url_inputs(NONE, STATE) + self.assertEqual(e, client.ERR_USERNAME) + + def test_success(self): + """ + Test _validate_create_auth_url_inputs + does not throw an error for valid inputs + """ + self.client._validate_create_auth_url_inputs(USERNAME, STATE) + + +if __name__ == '__main__': + unittest.main()