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 0000000..e7b5a78 Binary files /dev/null and b/demo/static/images/logo.png differ diff --git a/demo/static/style.css b/demo/static/style.css new file mode 100644 index 0000000..5aaee3a --- /dev/null +++ b/demo/static/style.css @@ -0,0 +1,56 @@ +body, input { + font: 17px arial, sans-serif; +} + +.content, +.input-form { + display: flex; + flex-direction: column; + align-items: center; +} + +img { + padding: 20px; + height: 84px; + margin-top: 40px; +} + +input { + width: 250px; + padding: 12px; + margin-top: 10px; +} + +input[type=text] { + margin-left: 44px; +} + +input[type=password] { + margin-left: 10px; +} + +h3 { + margin-top: 23px; +} + +pre.language-json { + font-size: 20px; +} + +div.success { + margin-top: 20px; +} + +pre.auth-token { + display: inline-block; + text-align: left; +} + +button { + background-color: #6BBF4E; + color: white; + border: none; + padding: 9px 18px; + margin-top: 17px; + border-radius: 4px; +} diff --git a/demo/templates/login.html b/demo/templates/login.html new file mode 100644 index 0000000..875d656 --- /dev/null +++ b/demo/templates/login.html @@ -0,0 +1,26 @@ + + +
+ + +
+
{{ 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()