From 4dc3ed36c32eda3b348cbab5e2b55de6653e614a Mon Sep 17 00:00:00 2001 From: Mateusz Mandera Date: Sun, 29 Sep 2019 06:32:56 +0200 Subject: [PATCH] auth: Add initial SAML authentication support. There are a few outstanding issues that we expect to resolve beforce including this in a release, but this is good checkpoint to merge. This PR is a collaboration with Tim Abbott. Fixes #716. --- docs/production/authentication-methods.md | 91 ++++++++ docs/subsystems/auth.md | 23 ++ .../images/landing-page/logos/saml-icon.png | Bin 0 -> 29323 bytes static/styles/portico/portico-signin.scss | 5 + templates/zerver/config_error.html | 13 ++ zerver/migrations/0250_saml_auth.py | 21 ++ zerver/models.py | 3 +- zerver/tests/fixtures/saml/idp.crt | 21 ++ zerver/tests/fixtures/saml/samlresponse.txt | 33 +++ zerver/tests/fixtures/saml/zulip.crt | 21 ++ zerver/tests/fixtures/saml/zulip.key | 28 +++ zerver/tests/test_auth_backends.py | 215 +++++++++++++++++- zerver/tests/test_docs.py | 8 + zerver/views/auth.py | 69 +++++- zproject/backends.py | 128 +++++++++++ zproject/dev_settings.py | 4 + zproject/prod_settings_template.py | 59 ++++- zproject/settings.py | 35 +++ zproject/test_settings.py | 35 +++ zproject/urls.py | 4 + 20 files changed, 807 insertions(+), 9 deletions(-) create mode 100644 static/images/landing-page/logos/saml-icon.png create mode 100644 zerver/migrations/0250_saml_auth.py create mode 100644 zerver/tests/fixtures/saml/idp.crt create mode 100644 zerver/tests/fixtures/saml/samlresponse.txt create mode 100644 zerver/tests/fixtures/saml/zulip.crt create mode 100644 zerver/tests/fixtures/saml/zulip.key diff --git a/docs/production/authentication-methods.md b/docs/production/authentication-methods.md index 995f02577e..1b5edbbbc0 100644 --- a/docs/production/authentication-methods.md +++ b/docs/production/authentication-methods.md @@ -34,6 +34,97 @@ Each of these requires one to a handful of lines of configuration in `settings.py`, as well as a secret in `zulip-secrets.conf`. Details are documented in your `settings.py`. +## SAML + +Zulip 2.1 and later has beta support for SAML authentication, used by +Okta, OneLogin, and many other IdPs (identity providers). You can +configure it as follows: + +1. These instructions assume you have an installed Zulip server. You + can have created an organization already using EmailAuthBackend, or + plan to create the organization using SAML authentication. + +1. Tell your IdP how to find your Zulip server: + + * **SP Entity ID**: `https://yourzulipdomain.example.com`. + * **SSO URL**: + `https://yourzulipdomain.example.com/complete/saml/`. This is + the "SAML ACS url" in SAML terminology. + + The `Entity ID` should match the value of + `SOCIAL_AUTH_SAML_SP_ENTITY_ID` computed in the Zulip settings. + You can run on your Zulip server + `/home/zulip/deployments/current/scripts/setup/get-django-setting + SOCIAL_AUTH_SAML_SP_ENTITY_ID` to get the computed value. + +2. Tell Zulip how to connect to your SAML provider server by filling + out the section of `/etc/zulip/settings.py` on your Zulip server + with the heading "SAML Authentication". + * You will need to update `SOCIAL_AUTH_SAML_ORG_INFO` with your + organization name (`displayname` may appear in the SAML + authentication flow; `name` won't be displayed to humans). + * Fill out `SOCIAL_AUTH_SAML_ENABLED_IDPS` with data provided by + your identity provider. You may find [the python-social-auth + SAML + docs](https://python-social-auth-docs.readthedocs.io/en/latest/backends/saml.html) + helpful. You'll need to obtain several values from your IdP's + metadata and enter them on the right-hand side of this + Python dictionary: + 1. Set the outer `idp_name` key to be an identifier for your IdP, + e.g. `testshib` or `okta`. This field may be used later if + Zulip adds support for declaring multiple IdPs here. + 2. The IdP should provide the `url` and `entity_id` values. + 3. Save the `x509cert` value to a file; you'll use it in the + instructions below. + 4. The values needed in the `attr_` fields are often configurable + in your IdP's interface when setting up SAML authentication + (referred to as "Attribute Statements" with Okta, or + "Attribute Mapping" with GSuite). You'll want to connect + these so that Zulip gets the email address (used as a unique + user ID) and name for the user. + +3. Install the certificate(s) required for SAML authentication. You + will definitely need the public certificate of your IdP. Some IdP + providers also support the Zulip server (Service Provider) having + a certificate used for encryption and signing. We detail these + steps as optional below, because they aren't required for basic + setup, and some IdPs like Okta don't fully support Service + Provider certificates. You should install them as follows: + + 1. On your Zulip server, `mkdir -p /etc/zulip/saml/idps/` + 2. Put the IDP public certificate in `/etc/zulip/saml/idps/{idp_name}.crt` + 3. (Optional) Put the Zulip server public certificate in `/etc/zulip/saml/zulip-cert.crt` + 4. (Optional) Put the Zulip server private key in `/etc/zulip/saml/zulip-private-key.key` + 5. Set the proper permissions on these files and directories: + + ``` + chown -R zulip.zulip /etc/zulip/saml/ + find /etc/zulip/saml/ -type f -exec chmod 644 -- {} + + chmod 640 /etc/zulip/saml/zulip-private-key.key + ``` + +4. (Optional) If you configured the optional public and private server + certificates above, you can enable the additional setting + `"authnRequestsSigned": True` in `SOCIAL_AUTH_SAML_SECURITY_CONFIG` + to have the SAMLRequests the server will be issuing to the IdP + signed using those certificates. Additionally, if the IdP supports + it, you can upload the public certificate to enable encryption of + assertions in the SAMLResponses the IdP will send about + authenticated users. + +5. Enable the `zproject.backends.SAMLAuthBackend` auth backend, in +`AUTHENTICATION_BACKENDS` in `/etc/zulip/settings.py`. + +6. [Restart the Zulip server](settings.html) to ensure your settings +changes take effect. The Zulip login page should now have a button +for SAML authentication that you can use to login or create an account +(including when creating a new organization). + +7. If the configuration was successful, the server's metadata can be +found at `https://yourzulipdomain.example.com/saml/metadata.xml`. You +can use this for verifying your configuration or provide it to your +IdP. + ```eval_rst .. _ldap: ``` diff --git a/docs/subsystems/auth.md b/docs/subsystems/auth.md index a2173c3a98..9c7f818c66 100644 --- a/docs/subsystems/auth.md +++ b/docs/subsystems/auth.md @@ -56,6 +56,29 @@ Here are the full procedures for dev: `social_auth_github_key` to the client ID and `social_auth_github_secret` to the client secret. +### SAML + +* Register a SAML authentication with Okta at + https://zulipchat-admin.okta.com/admin/apps/saml-wizard/create. Specify: + * `http://localhost:9991/complete/saml/` for the "Single sign on URL"`. + * `http://localhost:9991` for the "Audience URI (SP Entity ID)". + * Skip "Default RelayState". + * Skip "Name ID format". + * Set 'Email` for "Application username format". + * Provide "Attribute statements" of `email` to `user.email`, + `first_name` to `user.firstName`, and `last_name` to `user.lastName`. +* Assign at least one account to the in the "Assignments" tab. Uou'll + be logging in using this email address in the development + environment (so make sure that email has an account and can login + to the target realm). +* Visit the big "Setup instructions" button on the "Sign on" tab. +* Edit `zproject/dev-secrets.conf` to add the two values provided: + * Set `saml_url = http...` from "Identity Provider Single Sign-On + URL". + * Set `saml_entity_id = http://...` from "Identity Provider Issuer". + * Download the certificate and put it at the path `zproject/dev_saml.cert`. +* Now you should have working SAML authentication! + ### When SSL is required Some OAuth providers (such as Facebook) require HTTPS on the callback diff --git a/static/images/landing-page/logos/saml-icon.png b/static/images/landing-page/logos/saml-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..13fe0e8d17fedf40abbedf93d6504dc440cfbb55 GIT binary patch literal 29323 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 D|K?L( literal 0 HcmV?d00001 diff --git a/static/styles/portico/portico-signin.scss b/static/styles/portico/portico-signin.scss index 8d95fbf6e4..333843fb20 100644 --- a/static/styles/portico/portico-signin.scss +++ b/static/styles/portico/portico-signin.scss @@ -632,6 +632,11 @@ button.login-social-button:active { box-shadow: 0px 1px 1px hsla(0, 0%, 0%, 0.3); } +.saml-wrapper button.login-social-button { + background-image: url('/static/images/landing-page/logos/saml-icon.png'); + width: 100%; +} + .google-wrapper button.login-social-button { background-image: url('/static/images/landing-page/logos/googl_e-icon.png'); width: 100%; diff --git a/templates/zerver/config_error.html b/templates/zerver/config_error.html index 89c0f38333..e824a9378e 100644 --- a/templates/zerver/config_error.html +++ b/templates/zerver/config_error.html @@ -80,6 +80,19 @@ {% endif %} {% endif %} + {% if saml_error %} +

+ SAML authentication is either not enabled or misconfigured. Have a look at + our setup guide. +

+ {% if development_environment %} +

+ See also + the SAML + guide for the development environment. +

+ {% endif %} + {% endif %}

After making your changes, remember to restart the Zulip server.

diff --git a/zerver/migrations/0250_saml_auth.py b/zerver/migrations/0250_saml_auth.py new file mode 100644 index 0000000000..871450abb9 --- /dev/null +++ b/zerver/migrations/0250_saml_auth.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-09-19 00:50 +from __future__ import unicode_literals + +import bitfield.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('zerver', '0249_userprofile_role_finish'), + ] + + operations = [ + migrations.AlterField( + model_name='realm', + name='authentication_methods', + field=bitfield.models.BitField(['Google', 'Email', 'GitHub', 'LDAP', 'Dev', 'RemoteUser', 'AzureAD', 'SAML'], default=2147483647), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index ab9767218e..fc91ffcb22 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -139,7 +139,8 @@ class Realm(models.Model): MAX_GOOGLE_HANGOUTS_DOMAIN_LENGTH = 255 # This is just the maximum domain length by RFC INVITES_STANDARD_REALM_DAILY_MAX = 3000 MESSAGE_VISIBILITY_LIMITED = 10000 - AUTHENTICATION_FLAGS = [u'Google', u'Email', u'GitHub', u'LDAP', u'Dev', u'RemoteUser', u'AzureAD'] + AUTHENTICATION_FLAGS = [u'Google', u'Email', u'GitHub', u'LDAP', u'Dev', + u'RemoteUser', u'AzureAD', u'SAML'] SUBDOMAIN_FOR_ROOT_DOMAIN = '' # User-visible display name and description used on e.g. the organization homepage diff --git a/zerver/tests/fixtures/saml/idp.crt b/zerver/tests/fixtures/saml/idp.crt new file mode 100644 index 0000000000..32ecea560e --- /dev/null +++ b/zerver/tests/fixtures/saml/idp.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUeoIaZ6d61p7x4Zth70B0228j29AwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTA5MjkwMTExMDFaFw0yOTA5 +MjgwMTExMDFaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDnmQlCXt/Lwf1moKuP3rnfWHClwH7f3R0fjAaLARR7 +3rRSbvhoCWrmQ2w8ZO7vtDh+rivqqsIW0AjPcBRoPzzzXYGTi/1S7tmEQcOV/oMP +WciDIkQ6Nzg/3m8vvyb92/cI7/nTTChKIQI3K6uwZ1b8mvWt2MWhxvUUZyWWzyGw +J+JvWRtRznnUaPjg7hvYfDbekAQ1avKRQ0uTxkj3x7Dt6PxgO9zG14jXi6R+vfDA +YzLpcB+DCP48mMVWmtSWUpVEKQHrHP+EKOr7j8FyOXl0FfqkVD3xmXvirwgwv/Ny +FMWUCcZddbBBENOMty1dKTqnU0ei7IT7IHc7aWemibb9AgMBAAGjUzBRMB0GA1Ud +DgQWBBRdGAHn9nAevooUt1kpppwOeaC2nTAfBgNVHSMEGDAWgBRdGAHn9nAevooU +t1kpppwOeaC2nTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCs +sJutJ32iQO2LhEtYA9tut0BoD/GAb6JFwPUX7HXcX5iouRCNgF6fsMgCFVlyRn5V +xyPsuz7rjumEmKIfmEbhTz/WYYBPGI8R+7MgOVCXZX+8r2+GxDnLOI9g5ypsWV6B +nyFjGF/ldFieY+nhFL7HZ5s4AXM+PNHoguG00qE5nLcD160w1wun5rTXQRyNWyrf +Nzoc2kTnXY8RLKVqMx7FzakPJ4CfLak/c2iiAU2ug7tEW/3OGdZeb2m56b8dl4GY +3YStArY9y4S+DT690fagfVrlkj0zaWHsWcWdyzkLdNWFOxNWv5nVI/rkyawl0OyO ++nQdIbP+XrzXpGPVt5qL +-----END CERTIFICATE----- diff --git a/zerver/tests/fixtures/saml/samlresponse.txt b/zerver/tests/fixtures/saml/samlresponse.txt new file mode 100644 index 0000000000..f99d14be98 --- /dev/null +++ b/zerver/tests/fixtures/saml/samlresponse.txt @@ -0,0 +1,33 @@ +http://www.okta.com/exk1da4osrIL3Y7ip357P/e+Gdz179UAcrrPZW2R9hzxMlSAGwbZ+Ogksp7Rzlg=H1eepG122h3jzIqorofI6sr636xVFFtqsN0Vj5eb9YoFN3KMDH1AqzvGbzA+XEoT/1vle/D2n1A0qMv6UrnMy0EgrZlA+Mx3MgcQDhFoIqI7lV48I2aJ+G1+FvTrzt1hhfn6SBTorhc3M2+ST9z68V8mLsNXr82GveL/Ej5J4rxbQQ0Jxaic3luAkV0EhROqiSDwC7e/45II34e3sdtQ9bbnf3feDbovklb7Daa/NIqWpWX+0Y9qhHo1zx05oPiGZFtveJHiUbFXPpjR0r1juuG3HTGkORhRHCMYnpz73NsmuBTkAgYE+G0vUr0k5Sk28efS15ZZuAyiN+XCjl6SzQ==MIIDojCCAoqgAwIBAgIGAW0svbVqMA0GCSqGSIb3DQEBCwUAMIGRMQswCQYDVQQGEwJVUzETMBEG +A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU +MBIGA1UECwwLU1NPUHJvdmlkZXIxEjAQBgNVBAMMCXp1bGlwY2hhdDEcMBoGCSqGSIb3DQEJARYN +aW5mb0Bva3RhLmNvbTAeFw0xOTA5MTMyMjI3MTNaFw0yOTA5MTMyMjI4MTNaMIGRMQswCQYDVQQG +EwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UE +CgwET2t0YTEUMBIGA1UECwwLU1NPUHJvdmlkZXIxEjAQBgNVBAMMCXp1bGlwY2hhdDEcMBoGCSqG +SIb3DQEJARYNaW5mb0Bva3RhLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3K +E57ZDDVplrZO1RKpz9zFekhXZZFiPhW2TCTtoaI966sGaRCmV10cb1FCUxxI3ilcjY8G5irHYc5O +D4S8+FeIaHb036VjtZZNCDkamE2zGZCix5wCpXhxhQrXkkPJbzO4IGW896O43FPwefGfYnPC8/Oj +bZ0OUuR8KkNbgn2VnqwZtmb0EX5xrA+212UDyVQ7izVXOoBbvzeydLh8EWteEXjKBREKGBfCL9Kl +x8JY7BlYrZx+13NeDQsL7bgTXMnTIp3MVP3xddRqsatwersRVGr9b/HzXxfwu/MU230swjsNlgLZ +OXiYD43rNEkJRlFfMnlY8F3IoE1Mki2BJtMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAI8Xk13NT +rjo417U1+wZjUKqvB7iw4zGapMqpGRVavGw9hZmSCs8/AJAkFZQKMhAR9GGqf7JHHj/4fNEQ+XVh +YF1jCUR/X2VwUiBseDHaUKj7EZiX9tIFEI/6LVfPRjKNy1RkEXHo7Lg4RnctclZ1KU7mIZkPSk1J +fShKIUhtvNaCYJ4OVkN+giQQ6u9HwBqoBYikOBhvgXfIlBFD5H1n7JqxOjWZNO7Rhhx+TjD/0Dmd +BE04J2bv5zCllBWSv2e1YFi+5SBTBq6FaIMz5c8T4WRFpRlnDEvvEREeAsAbaDiYvpJlO6hOzHNj +KVmHpmQsDhsgXP02tDsfTARf3EXhbA==http://www.okta.com/exk1da4osrIL3Y7ip357J5+QPDVShpm1VpG+dAjbU5GNrRwkEVMQ3MthoFukiH4=RWvjlS6nr7MUm6JzSn71nD3nkM77bja8Mnfy8GZYL0tQwtLBdixgW+oUF8jSXId9/dkYKCcq1n3fxzyX1iMkAeF7YcUlmpfN56hRKECzdWmaaceCS7s15vTqN/Gy83AFWp6d/nBbyt25UnGtmOSJjU5+QmBBB/JJO2EjtCiJZlJkJy3V1nOU/PnJ5p3iutUNtn17gqVYKkWix8b95xdoOHEZLC/0w8pt6OOLePlg+HafCg0XA7jS3g4+vPagcAEhSBIEpX9rZVdaWZdpP5NxHbjtyG979n5tzx7ooVBebrEfPdneoQeZQabNU/jUeeWXBJNaQ3Rv59EidOaTI68LZA==MIIDojCCAoqgAwIBAgIGAW0svbVqMA0GCSqGSIb3DQEBCwUAMIGRMQswCQYDVQQGEwJVUzETMBEG +A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU +MBIGA1UECwwLU1NPUHJvdmlkZXIxEjAQBgNVBAMMCXp1bGlwY2hhdDEcMBoGCSqGSIb3DQEJARYN +aW5mb0Bva3RhLmNvbTAeFw0xOTA5MTMyMjI3MTNaFw0yOTA5MTMyMjI4MTNaMIGRMQswCQYDVQQG +EwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UE +CgwET2t0YTEUMBIGA1UECwwLU1NPUHJvdmlkZXIxEjAQBgNVBAMMCXp1bGlwY2hhdDEcMBoGCSqG +SIb3DQEJARYNaW5mb0Bva3RhLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3K +E57ZDDVplrZO1RKpz9zFekhXZZFiPhW2TCTtoaI966sGaRCmV10cb1FCUxxI3ilcjY8G5irHYc5O +D4S8+FeIaHb036VjtZZNCDkamE2zGZCix5wCpXhxhQrXkkPJbzO4IGW896O43FPwefGfYnPC8/Oj +bZ0OUuR8KkNbgn2VnqwZtmb0EX5xrA+212UDyVQ7izVXOoBbvzeydLh8EWteEXjKBREKGBfCL9Kl +x8JY7BlYrZx+13NeDQsL7bgTXMnTIp3MVP3xddRqsatwersRVGr9b/HzXxfwu/MU230swjsNlgLZ +OXiYD43rNEkJRlFfMnlY8F3IoE1Mki2BJtMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAI8Xk13NT +rjo417U1+wZjUKqvB7iw4zGapMqpGRVavGw9hZmSCs8/AJAkFZQKMhAR9GGqf7JHHj/4fNEQ+XVh +YF1jCUR/X2VwUiBseDHaUKj7EZiX9tIFEI/6LVfPRjKNy1RkEXHo7Lg4RnctclZ1KU7mIZkPSk1J +fShKIUhtvNaCYJ4OVkN+giQQ6u9HwBqoBYikOBhvgXfIlBFD5H1n7JqxOjWZNO7Rhhx+TjD/0Dmd +BE04J2bv5zCllBWSv2e1YFi+5SBTBq6FaIMz5c8T4WRFpRlnDEvvEREeAsAbaDiYvpJlO6hOzHNj +KVmHpmQsDhsgXP02tDsfTARf3EXhbA=={email}http://zulip.testserverurn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport{first_name}{last_name}{email} diff --git a/zerver/tests/fixtures/saml/zulip.crt b/zerver/tests/fixtures/saml/zulip.crt new file mode 100644 index 0000000000..df94012443 --- /dev/null +++ b/zerver/tests/fixtures/saml/zulip.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUdxMUceyfJ0JWc9+631OR8cJDxREwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTA5MjQyMjAwMDFaFw0yOTA5 +MjMyMjAwMDFaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDk5VXGfX6rAwkCoNYTTCsmZLcmhNW8KXbr0+giMHJ2 +1wswiFZadOevRbgEKeB6b7d/4G0JNTtcTKrq3LqBX7YiQqRsUegBMf1Ev8Gsx8c3 +LJKreOd2NdN0CKgEYtm0YAkOwoM4Idg5JUzfDHOJN6Q/ktUsUD8/LERjDzrLGTte +A/HThow++1HIUKQCubzJXqyehC9/+gz17bCRq5XBaKB5oxjq0c8tNU6rFXnbYVZJ +/v6OpKfzzg+BV73VVQWtfT40Aco5kBhp6gCjj3N1UQHX9KgEmtQpBHSqb9YbWGcn +CILJ7HUgRdRVf0TEj83bT/IEmgAw0DOaDbWIjmUTsOW3AgMBAAGjUzBRMB0GA1Ud +DgQWBBSXe/5beCNwVuyWNfQLiET1HYrjaTAfBgNVHSMEGDAWgBSXe/5beCNwVuyW +NfQLiET1HYrjaTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQ +n4tWLuO2pcJzN4s8xJdG19Qne+H2N1mfRgzfSOIAAHWYXuP8i2JXp+QgPRai/Jse +54p3WRM2JWrGicjgx/XDAsDv+SyZB3uF1r5yPGdKt05VRC/wiGh3p0rosGr5+8B0 +lICHeKPgOg0pCX3k3iU15ZkT8OA+5IQmyonB3gxtAjkUKTTUjd5gnIY8KIbcfrTw +iJMPLLumpUx1NMbEcmcB1HKT2YHwPUh93d6FA5WAr0y9swke4VhI2vXTovwnR9CU +oXbZrHH231O6SDaccl0/qFp6FDuSogRt9oiZorw8kk1NPyx21mqfmCgZ6tAZ7SJZ +0ppBMmyTLo1PL86rZcF9 +-----END CERTIFICATE----- diff --git a/zerver/tests/fixtures/saml/zulip.key b/zerver/tests/fixtures/saml/zulip.key new file mode 100644 index 0000000000..8ef2a150d0 --- /dev/null +++ b/zerver/tests/fixtures/saml/zulip.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDk5VXGfX6rAwkC +oNYTTCsmZLcmhNW8KXbr0+giMHJ21wswiFZadOevRbgEKeB6b7d/4G0JNTtcTKrq +3LqBX7YiQqRsUegBMf1Ev8Gsx8c3LJKreOd2NdN0CKgEYtm0YAkOwoM4Idg5JUzf +DHOJN6Q/ktUsUD8/LERjDzrLGTteA/HThow++1HIUKQCubzJXqyehC9/+gz17bCR +q5XBaKB5oxjq0c8tNU6rFXnbYVZJ/v6OpKfzzg+BV73VVQWtfT40Aco5kBhp6gCj +j3N1UQHX9KgEmtQpBHSqb9YbWGcnCILJ7HUgRdRVf0TEj83bT/IEmgAw0DOaDbWI +jmUTsOW3AgMBAAECggEAfCCyB1X+3xZiSH6YGRbxP3zWpZjbn5KM3w6nkALdz/yG +IOeOjLdg/Pe99uQOy9bRmBNIjfnEGyWoen0A1y/kQWgKaoNwYVWOlz219dDRA+a0 +EzEZtE00QnR/SQGiNeLuhoaNSl9wNm035q2F6h+2fpNN7x4Fbmi/HUkhBQrF2xEZ +vK6WXb5uHiWYJ4wIEw5nc/DMY3NZ6vJgU+T6uMkj1llhlTtKFFNnRS9yrt46D0Bb +yJEP+9RhmSkAjZyKICYNh/r7Me0CU3AOHnyAR6Tyz7KweupE91INclzPuL2HEd6R +05TntfqC+yimwa0AY4dX+cUntr82B5KarWur94Uj4QKBgQD9az4OcIPV7qEobVwo +eqfgJc9sN0806ESgzCxIRut/RPNykr76zE6tYPQbzAi9iQQYi+yXVkZddUWWPtht +qTv9LIuGb0NqKLUQYx2tpyh0gFnJJHyKSmooIfoKDEkSLWyPzn2tKPrGOQ683tjX +vMGNfUIyaHMsidRwOs58oiweswKBgQDnOibfvPjEVrSYRcimUmm8WS7tCBJFd5LL +cL//NfHQ92SX6z33j71Qz80ezLIrHHizhjaxPTxXGny/unc8j3njM15ilZmNB9Lg +wtTW3H+MamFF3nTI6/M1iaAM2OIY9nTwZv/UMMmBuVsNOa0mdo1FxxF9ik8MQibB +vsO65YKe7QKBgQCamE2nKWSDoauWqgBKgWjgCLDc53DeacNUBLoO7ZTEcx/AiV0Q +SorEohzIyFOcrHVfNB0ExZDvepcU7QnC/DaoYABN5ppNrL+oW47DXPIFADfFyQhg +pLzV9sQ+VPhOqn9Ly0BH3nP9cNlYxump0nCRDBTSA34fcYWzYWyOA7C+mQKBgQCS +UjpHW04Q8M1XjtFqbrx6c/U+Cd2GGCTMmIzm8zwTAHqnqDWOc2dZvCYRV3dn0JyQ +/l2dyyJj/F709Qp/SEvZeqg/umtw04KeuKv3S5FrSeZEUIGWo7lEJ9MgTh7FrTBS +8Nrza+wYKzNzKwxnSp4bid2Hk/5xw2rDL/SsUJBYAQKBgQDqeuuosCuynOZKF/Vy +tMZ7ms1GQzfmnkh1uA+OdTMkxwN/DXbJJwo89EouPN+KVpdM8GlvWRZesrVtqSpO +wK94UzIVrejv6sLUz33tTyRoMrPmkQ9r6KdpHrHwTAJzfceeqHwK1XUCEf/ht4A6 +0zaX2kWvylQBjsD293QfCerl3A== +-----END PRIVATE KEY----- diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 0b071e1157..8fad80a35d 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -56,16 +56,19 @@ from zproject.backends import ZulipDummyBackend, EmailAuthBackend, \ require_email_format_usernames, AUTH_BACKEND_NAME_MAP, \ ZulipLDAPConfigurationError, ZulipLDAPExceptionOutsideDomain, \ ZulipLDAPException, query_ldap, sync_user_from_ldap, SocialAuthMixin, \ - PopulateUserLDAPError + PopulateUserLDAPError, SAMLAuthBackend, saml_auth_enabled from zerver.views.auth import (maybe_send_to_registration, _subdomain_token_salt) from version import ZULIP_VERSION +from onelogin.saml2.auth import OneLogin_Saml2_Auth +from onelogin.saml2.response import OneLogin_Saml2_Response from social_core.exceptions import AuthFailed, AuthStateForbidden from social_django.strategy import DjangoStrategy from social_django.storage import BaseDjangoStorage +import base64 import json import urllib import ujson @@ -956,6 +959,216 @@ class SocialAuthBase(ZulipTestCase): self.assertEqual(result.status_code, 302) self.assertIn('login', result.url) +class SAMLAuthBackendTest(SocialAuthBase): + __unittest_skip__ = False + + BACKEND_CLASS = SAMLAuthBackend + LOGIN_URL = "/accounts/login/social/saml" + SIGNUP_URL = "/accounts/register/social/saml" + AUTHORIZATION_URL = "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" + AUTH_FINISH_URL = "/complete/saml/" + CONFIG_ERROR_URL = "/config-error/saml" + + # We have to define our own social_auth_test as the flow of SAML authentication + # is different from the other social backends. + def social_auth_test(self, account_data_dict: Dict[str, str], + *, subdomain: Optional[str]=None, + mobile_flow_otp: Optional[str]=None, + is_signup: Optional[str]=None, + next: str='', + multiuse_object_key: str='', + **extra_data: Any) -> HttpResponse: + url, headers = self.prepare_login_url_and_headers( + subdomain, mobile_flow_otp, is_signup, next, multiuse_object_key + ) + + result = self.client_get(url, **headers) + + expected_result_url_prefix = 'http://testserver/login/%s/' % (self.backend.name,) + if settings.SOCIAL_AUTH_SUBDOMAIN is not None: + expected_result_url_prefix = ( + 'http://%s.testserver/login/%s/' % (settings.SOCIAL_AUTH_SUBDOMAIN, self.backend.name) + ) + + if result.status_code != 302 or not result.url.startswith(expected_result_url_prefix): + return result + + result = self.client_get(result.url, **headers) + + self.assertEqual(result.status_code, 302) + assert self.AUTHORIZATION_URL in result.url + assert "samlrequest" in result.url.lower() + + self.client.cookies = result.cookies + parsed_url = urllib.parse.urlparse(result.url) + relay_state = urllib.parse.parse_qs(parsed_url.query)['RelayState'][0] + # Make sure params are getting encoded into RelayState: + data = SAMLAuthBackend.get_data_from_redis(relay_state) + if next: + self.assertEqual(data['next'], next) + if is_signup: + self.assertEqual(data['is_signup'], is_signup) + + saml_response = self.generate_saml_response(**account_data_dict) + post_params = {"SAMLResponse": saml_response, "RelayState": relay_state} + # The mock below is necessary, so that python3-saml accepts our SAMLResponse, + # and doesn't verify the cryptographic signatures etc., since generating + # a perfectly valid SAMLResponse for the purpose of these tests would be too complex, + # and we simply use one loaded from a fixture file. + with mock.patch.object(OneLogin_Saml2_Response, 'is_valid', return_value=True): + result = self.client_post(self.AUTH_FINISH_URL, post_params, **headers) + + return result + + def generate_saml_response(self, email: str, name: str) -> str: + """ + The samlresponse.txt fixture has a pre-generated SAMLResponse, + with {email}, {first_name}, {last_name} placeholders, that can + be filled out with the data we want. + """ + name_parts = name.split(' ') + first_name = name_parts[0] + last_name = name_parts[1] + + unencoded_saml_response = self.fixture_data("samlresponse.txt", type="saml").format( + email=email, + first_name=first_name, + last_name=last_name + ) + # SAMLResponse needs to be base64-encoded. + saml_response = base64.b64encode(unencoded_saml_response.encode()).decode() # type: str + + return saml_response + + def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]: + return dict(email=email, name=name) + + def test_social_auth_no_key(self) -> None: + """ + Since in the case of SAML there isn't a direct equivalent of CLIENT_KEY_SETTING, + we override this test, to test for the case where the obligatory + SOCIAL_AUTH_SAML_ENABLED_IDPS isn't configured. + """ + account_data_dict = self.get_account_data_dict(email=self.email, name=self.name) + with self.settings(SOCIAL_AUTH_SAML_ENABLED_IDPS=None): + result = self.social_auth_test(account_data_dict, + subdomain='zulip', next='/user_uploads/image') + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, self.CONFIG_ERROR_URL) + + def test_saml_auth_works_without_private_public_keys(self) -> None: + with self.settings(SOCIAL_AUTH_SAML_SP_PUBLIC_CERT='', SOCIAL_AUTH_SAML_SP_PRIVATE_KEY=''): + self.test_social_auth_success() + + def test_saml_auth_enabled(self) -> None: + with self.settings(AUTHENTICATION_BACKENDS=('zproject.backends.SAMLAuthBackend',)): + self.assertTrue(saml_auth_enabled()) + result = self.client_get("/saml/metadata.xml") + self.assert_in_success_response( + ['entityID="{}"'.format(settings.SOCIAL_AUTH_SAML_SP_ENTITY_ID)], result + ) + + def test_social_auth_complete(self) -> None: + with mock.patch.object(OneLogin_Saml2_Response, 'is_valid', return_value=True): + with mock.patch.object(OneLogin_Saml2_Auth, 'is_authenticated', return_value=False), \ + mock.patch('zproject.backends.logging.info') as m: + # This mock causes AuthFailed to be raised. + saml_response = self.generate_saml_response(self.email, self.name) + relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp"}) + post_params = {"SAMLResponse": saml_response, "RelayState": relay_state} + result = self.client_post('/complete/saml/', post_params) + self.assertEqual(result.status_code, 302) + self.assertIn('login', result.url) + m.assert_called_with("Authentication failed: SAML login failed: [] (None)") + + def test_social_auth_complete_when_base_exc_is_raised(self) -> None: + with mock.patch.object(OneLogin_Saml2_Response, 'is_valid', return_value=True): + with mock.patch('social_core.backends.saml.SAMLAuth.auth_complete', + side_effect=AuthStateForbidden('State forbidden')), \ + mock.patch('zproject.backends.logging.warning') as m: + saml_response = self.generate_saml_response(self.email, self.name) + relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp"}) + post_params = {"SAMLResponse": saml_response, "RelayState": relay_state} + result = self.client_post('/complete/saml/', post_params) + self.assertEqual(result.status_code, 302) + self.assertIn('login', result.url) + m.assert_called_with("Wrong state parameter given.") + + def test_social_auth_complete_bad_params(self) -> None: + # Simple GET for /complete/saml without the required parameters. + # This tests the auth_complete wrapped in our SAMLAuthBackend, + # ensuring it prevents this requests from causing an internal server error. + with mock.patch('zproject.backends.logging.info') as m: + result = self.client_get('/complete/saml/') + self.assertEqual(result.status_code, 302) + self.assertIn('login', result.url) + m.assert_called_with("SAML authentication failed: missing RelayState.") + + # Check that POSTing the RelayState, but with missing SAMLResponse, + # doesn't cause errors either: + with mock.patch('zproject.backends.logging.info') as m: + relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp"}) + post_params = {"RelayState": relay_state} + result = self.client_post('/complete/saml/', post_params) + self.assertEqual(result.status_code, 302) + self.assertIn('login', result.url) + m.assert_called_with( + # OneLogin_Saml2_Error exception: + "SAML Response not found, Only supported HTTP_POST Binding" + ) + + with mock.patch('zproject.backends.logging.info') as m: + relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp"}) + relay_state = relay_state[:-1] # Break the token by removing the last character + post_params = {"RelayState": relay_state} + result = self.client_post('/complete/saml/', post_params) + self.assertEqual(result.status_code, 302) + self.assertIn('login', result.url) + m.assert_called_with("SAML authentication failed: bad RelayState token.") + + def test_social_auth_saml_bad_idp_param_on_login_page(self) -> None: + with mock.patch('zproject.backends.logging.info') as m: + result = self.client_get('/login/saml/') + self.assertEqual(result.status_code, 302) + self.assertEqual('/login/', result.url) + m.assert_called_with("/login/saml/ : Bad idp param.") + + with mock.patch('zproject.backends.logging.info') as m: + result = self.client_get('/login/saml/?idp=bad_idp') + self.assertEqual(result.status_code, 302) + self.assertEqual('/login/', result.url) + m.assert_called_with("/login/saml/ : Bad idp param.") + + def test_social_auth_invalid_email(self) -> None: + """ + This test needs an override from the original class. For security reasons, + the 'next' and 'mobile_flow_otp' params don't get passed on in the session + if the authentication attempt failed. See SAMLAuthBackend.auth_complete for details. + """ + account_data_dict = self.get_account_data_dict(email="invalid", name=self.name) + result = self.social_auth_test(account_data_dict, + expect_choose_email_screen=True, + subdomain='zulip', next='/user_uploads/image') + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, "/login/") + + def test_social_auth_saml_multiple_idps_configured(self) -> None: + """ + Using multiple IdPs is not supported right now, and having multiple configured + should lead to misconfiguration page. + """ + + with self.settings(SOCIAL_AUTH_SAML_ENABLED_IDPS={"test_idp1": {}, "test_idp2": {}}): + # We don't need to put full idp configurations in the mock settings above + # to trigger the error. + with mock.patch("zerver.views.auth.logging.error") as mock_error: + result = self.client_get("/accounts/login/social/saml") + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, '/config-error/saml') + mock_error.assert_called_once_with( + "SAML misconfigured - you have specified multiple IdPs. Only one IdP is supported." + ) + class GitHubAuthBackendTest(SocialAuthBase): __unittest_skip__ = False diff --git a/zerver/tests/test_docs.py b/zerver/tests/test_docs.py index 1819fd0e25..97ea423003 100644 --- a/zerver/tests/test_docs.py +++ b/zerver/tests/test_docs.py @@ -412,6 +412,14 @@ class ConfigErrorTest(ZulipTestCase): self.assert_not_in_success_response(["zproject/dev_settings.py"], result) self.assert_not_in_success_response(["zproject/dev-secrets.conf"], result) + @override_settings(SOCIAL_AUTH_SAML_ENABLED_IDPS=None) + def test_saml_error(self) -> None: + result = self.client_get("/accounts/login/social/saml") + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, '/config-error/saml') + result = self.client_get(result.url) + self.assert_in_success_response(["SAML authentication"], result) + def test_smtp_error(self) -> None: result = self.client_get("/config-error/smtp") self.assertEqual(result.status_code, 200) diff --git a/zerver/views/auth.py b/zerver/views/auth.py index 38244ef527..c98c7aba12 100644 --- a/zerver/views/auth.py +++ b/zerver/views/auth.py @@ -7,7 +7,8 @@ from django.contrib.auth.views import password_reset as django_password_reset from django.urls import reverse from zerver.decorator import require_post, \ process_client, do_login, log_view_func -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, \ + HttpResponseServerError from django.template.response import SimpleTemplateResponse from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt @@ -37,12 +38,14 @@ from zerver.models import PreregistrationUser, UserProfile, remote_user_to_email from zerver.signals import email_on_new_login from zproject.backends import password_auth_enabled, dev_auth_enabled, \ ldap_auth_enabled, ZulipLDAPConfigurationError, ZulipLDAPAuthBackend, \ - AUTH_BACKEND_NAME_MAP, auth_enabled_helper + AUTH_BACKEND_NAME_MAP, auth_enabled_helper, saml_auth_enabled from version import ZULIP_VERSION import jwt import logging +from social_django.utils import load_backend, load_strategy + from two_factor.forms import BackupTokenForm from two_factor.views import LoginView as BaseTwoFactorLoginView @@ -315,7 +318,8 @@ def remote_user_jwt(request: HttpRequest) -> HttpResponse: return login_or_register_remote_user(request, email, user_profile, remote_user) def oauth_redirect_to_root(request: HttpRequest, url: str, - sso_type: str, is_signup: bool=False) -> HttpResponse: + sso_type: str, is_signup: bool=False, + extra_url_params: Dict[str, str]={}) -> HttpResponse: main_site_uri = settings.ROOT_DOMAIN_URI + url if settings.SOCIAL_AUTH_SUBDOMAIN is not None and sso_type == 'social': main_site_uri = (settings.EXTERNAL_URI_SCHEME + @@ -342,23 +346,54 @@ def oauth_redirect_to_root(request: HttpRequest, url: str, if next: params['next'] = next + params = {**params, **extra_url_params} + return redirect(main_site_uri + '?' + urllib.parse.urlencode(params)) def start_social_login(request: HttpRequest, backend: str) -> HttpResponse: backend_url = reverse('social:begin', args=[backend]) + extra_url_params = {} # type: Dict[str, str] + if backend == "saml": + obligatory_saml_settings_list = [ + settings.SOCIAL_AUTH_SAML_SP_ENTITY_ID, + settings.SOCIAL_AUTH_SAML_ORG_INFO, + settings.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, + settings.SOCIAL_AUTH_SAML_SUPPORT_CONTACT, + settings.SOCIAL_AUTH_SAML_ENABLED_IDPS + ] + if any(not setting for setting in obligatory_saml_settings_list): + return redirect_to_config_error("saml") + + # This backend requires the name of the IdP (from the list of configured ones) + # to be passed as the parameter. + # Currently we support configuring only one IdP. + # TODO: Support multiple IdPs. python-social-auth SAML (which we use here) + # already supports that, so essentially only the UI for it on the login pages + # needs to be figured out. + if len(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS) != 1: + logging.error( + "SAML misconfigured - you have specified multiple IdPs. Only one IdP is supported." + ) + return redirect_to_config_error("saml") + extra_url_params = {'idp': list(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys())[0]} if (backend == "github") and not (settings.SOCIAL_AUTH_GITHUB_KEY and settings.SOCIAL_AUTH_GITHUB_SECRET): return redirect_to_config_error("github") if (backend == "google") and not (settings.SOCIAL_AUTH_GOOGLE_KEY and settings.SOCIAL_AUTH_GOOGLE_SECRET): return redirect_to_config_error("google") - # TODO: Add a similar block of AzureAD. + # TODO: Add a similar block for AzureAD. - return oauth_redirect_to_root(request, backend_url, 'social') + return oauth_redirect_to_root(request, backend_url, 'social', extra_url_params=extra_url_params) def start_social_signup(request: HttpRequest, backend: str) -> HttpResponse: backend_url = reverse('social:begin', args=[backend]) - return oauth_redirect_to_root(request, backend_url, 'social', is_signup=True) + extra_url_params = {} # type: Dict[str, str] + if backend == "saml": + assert len(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS) == 1 + extra_url_params = {'idp': list(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys())[0]} + return oauth_redirect_to_root(request, backend_url, 'social', is_signup=True, + extra_url_params=extra_url_params) def authenticate_remote_user(realm: Realm, email_address: Optional[str]) -> Optional[UserProfile]: @@ -851,3 +886,25 @@ def password_reset(request: HttpRequest, **kwargs: Any) -> HttpResponse: template_name='zerver/reset.html', password_reset_form=ZulipPasswordResetForm, post_reset_redirect='/accounts/password/reset/done/') + +@csrf_exempt +def saml_sp_metadata(request: HttpRequest, **kwargs: Any) -> HttpResponse: # nocoverage + """ + This is the view function for generating our SP metadata + for SAML authentication. It's meant for helping check the correctness + of the configuration when setting up SAML, or for obtaining the XML metadata + if the IdP requires it. + Taken from https://python-social-auth.readthedocs.io/en/latest/backends/saml.html + """ + if not saml_auth_enabled(): + return redirect_to_config_error("saml") + + complete_url = reverse('social:complete', args=("saml",)) + saml_backend = load_backend(load_strategy(request), "saml", + complete_url) + metadata, errors = saml_backend.generate_metadata_xml() + if not errors: + return HttpResponse(content=metadata, + content_type='text/xml') + + return HttpResponseServerError(content=', '.join(errors)) diff --git a/zproject/backends.py b/zproject/backends.py index 1330cb1747..f81edfa725 100644 --- a/zproject/backends.py +++ b/zproject/backends.py @@ -15,6 +15,7 @@ import copy import logging import magic +import ujson from typing import Any, Dict, List, Optional, Set, Tuple, Union from django_auth_ldap.backend import LDAPBackend, _LDAPUser, ldap_error @@ -28,12 +29,14 @@ from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse from requests import HTTPError +from onelogin.saml2.errors import OneLogin_Saml2_Error from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \ GithubTeamOAuth2 from social_core.backends.azuread import AzureADOAuth2 from social_core.backends.base import BaseAuth from social_core.backends.google import GoogleOAuth2 from social_core.backends.oauth import BaseOAuth2 +from social_core.backends.saml import SAMLAuth from social_core.pipeline.partial import partial from social_core.exceptions import AuthFailed, SocialAuthBaseException @@ -44,11 +47,15 @@ from zerver.lib.avatar_hash import user_avatar_content_hash from zerver.lib.dev_ldap_directory import init_fakeldap from zerver.lib.request import JsonableError from zerver.lib.users import check_full_name, validate_user_custom_profile_field +from zerver.lib.utils import generate_random_token +from zerver.lib.redis_utils import get_redis_client from zerver.models import CustomProfileField, DisposableEmailError, DomainNotAllowedForRealmError, \ EmailContainsPlusError, PreregistrationUser, UserProfile, Realm, custom_profile_fields_for_realm, \ email_allowed_for_realm, get_default_stream_groups, get_user_profile_by_id, remote_user_to_email, \ email_to_username, get_realm, get_user_by_delivery_email, supported_auth_backends +redis_client = get_redis_client() + # This first batch of methods is used by other code in Zulip to check # whether a given authentication backend is enabled for a given realm. # In each case, we both needs to check at the server level (via @@ -96,6 +103,9 @@ def google_auth_enabled(realm: Optional[Realm]=None) -> bool: def github_auth_enabled(realm: Optional[Realm]=None) -> bool: return auth_enabled_helper(['GitHub'], realm) +def saml_auth_enabled(realm: Optional[Realm]=None) -> bool: + return auth_enabled_helper(['SAML'], realm) + def any_social_backend_enabled(realm: Optional[Realm]=None) -> bool: """Used by the login page process to determine whether to show the 'OR' for login with Google""" @@ -1003,6 +1013,124 @@ class GoogleAuthBackend(SocialAuthMixin, GoogleOAuth2): verified_emails.append(details["email"]) return verified_emails +class SAMLAuthBackend(SocialAuthMixin, SAMLAuth): + auth_backend_name = "SAML" + standard_relay_params = ["subdomain", "multiuse_object_key", "mobile_flow_otp", + "next", "is_signup"] + REDIS_EXPIRATION_SECONDS = 60 * 15 + + def auth_url(self) -> str: + """Get the URL to which we must redirect in order to + authenticate the user. Overriding the original SAMLAuth.auth_url. + Runs when someone accesses the /login/saml/ endpoint.""" + try: + idp_name = self.strategy.request_data()['idp'] + auth = self._create_saml_auth(idp=self.get_idp(idp_name)) + except KeyError: + # If the above raise KeyError, it means invalid or no idp was specified, + # we should log that and redirect to the login page. + logging.info("/login/saml/ : Bad idp param.") + return reverse('zerver.views.auth.login_page', + kwargs = {'template_name': 'zerver/login.html'}) + + # This where we change things. We need to pass some params + # (`mobile_flow_otp`, `next`, etc.) through RelayState, which + # then the IdP will pass back to us so we can read those + # parameters in the final part of the authentication flow, at + # the /complete/saml/ endpoint. + # + # To protect against network eavesdropping of these + # parameters, we send just a random token to the IdP in + # RelayState, which is used as a key into our redis data store + # for fetching the actual parameters after the IdP has + # returned a successful authentication. + params_to_relay = ["idp"] + self.standard_relay_params + request_data = self.strategy.request_data().dict() + data_to_relay = { + key: request_data[key] for key in params_to_relay if key in request_data + } + relay_state = self.put_data_in_redis(data_to_relay) + + return auth.login(return_to=relay_state) + + @classmethod + def put_data_in_redis(cls, data_to_relay: Dict[str, Any]) -> str: + with redis_client.pipeline() as pipeline: + token = generate_random_token(64) + key = "saml_token_{}".format(token) + pipeline.set(key, ujson.dumps(data_to_relay)) + pipeline.expire(key, cls.REDIS_EXPIRATION_SECONDS) + pipeline.execute() + + return key + + @classmethod + def get_data_from_redis(cls, key: str) -> Optional[Dict[str, Any]]: + redis_data = None + if key.startswith('saml_token_'): + # Safety if statement, to not allow someone to poke around arbitrary redis keys here. + redis_data = redis_client.get(key) + if redis_data is None: + # TODO: We will need some sort of user-facing message + # about the authentication session having expired here. + logging.info("SAML authentication failed: bad RelayState token.") + return None + + return ujson.loads(redis_data) + + def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]: + """ + Additional ugly wrapping on top of auth_complete in SocialAuthMixin. + We handle two things here: + 1. Working around bad RelayState or SAMLResponse parameters in the request. + Both parameters should be present if the user came to /complete/saml/ through + the IdP as intended. The errors can happen if someone simply types the endpoint into + their browsers, or generally tries messing with it in some ways. + + 2. The first part of our SAML authentication flow will encode important parameters + into the RelayState. We need to read them and set those values in the session, + and then change the RelayState param to the idp_name, because that's what + SAMLAuth.auth_complete() expects. + """ + if 'RelayState' not in self.strategy.request_data(): + logging.info("SAML authentication failed: missing RelayState.") + return None + + # Set the relevant params that we transported in the RelayState: + redis_key = self.strategy.request_data()['RelayState'] + relayed_params = self.get_data_from_redis(redis_key) + if relayed_params is None: + return None + + result = None + try: + for param, value in relayed_params.items(): + if param in self.standard_relay_params: + self.strategy.session_set(param, value) + + # super().auth_complete expects to have RelayState set to the idp_name, + # so we need to replace this param. + post_params = self.strategy.request.POST.copy() + post_params['RelayState'] = relayed_params["idp"] + self.strategy.request.POST = post_params + + # Call the auth_complete method of SocialAuthMixIn + result = super().auth_complete(*args, **kwargs) # type: ignore # monkey-patching + except OneLogin_Saml2_Error as e: + # This will be raised if SAMLResponse is missing. + logging.info(str(e)) + # Fall through to returning None. + finally: + if result is None: + for param in self.standard_relay_params: + # If an attacker managed to eavesdrop on the RelayState token, + # they may pass it here to the endpoint with an invalid SAMLResponse. + # We remove these potentially sensitive parameters that we have set in the session + # ealier, to avoid leaking their values. + self.strategy.session_set(param, None) + + return result + AUTH_BACKEND_NAME_MAP = { 'Dev': DevAuthBackend, 'Email': EmailAuthBackend, diff --git a/zproject/dev_settings.py b/zproject/dev_settings.py index 24eb3b5f66..69df17cb59 100644 --- a/zproject/dev_settings.py +++ b/zproject/dev_settings.py @@ -45,6 +45,7 @@ AUTHENTICATION_BACKENDS = ( 'zproject.backends.EmailAuthBackend', 'zproject.backends.GitHubAuthBackend', 'zproject.backends.GoogleAuthBackend', + 'zproject.backends.SAMLAuthBackend', # 'zproject.backends.AzureADAuthBackend', ) @@ -157,3 +158,6 @@ TERMS_OF_SERVICE = 'corporate/terms.md' # header. Important for SAML authentication in the development # environment. USE_X_FORWARDED_PORT = True + +# Override the default SAML entity ID +SOCIAL_AUTH_SAML_SP_ENTITY_ID = "http://localhost:9991/" diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index e87e106adb..8be6e64c7b 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -120,6 +120,7 @@ AUTHENTICATION_BACKENDS = ( # 'zproject.backends.GoogleAuthBackend', # Google auth, setup below # 'zproject.backends.GitHubAuthBackend', # GitHub auth, setup below # 'zproject.backends.AzureADAuthBackend', # Microsoft Azure Active Directory auth, setup below + # 'zproject.backends.SAMLAuthBackend', # SAML, setup below # 'zproject.backends.ZulipLDAPAuthBackend', # LDAP, setup below # 'zproject.backends.ZulipRemoteUserBackend', # Local SSO, setup docs on readthedocs ) # type: Tuple[str, ...] @@ -185,6 +186,63 @@ AUTHENTICATION_BACKENDS = ( # #SOCIAL_AUTH_SUBDOMAIN = 'auth' +######## +# SAML Authentication +# +# For SAML authentication, you will need to configure the settings +# below using information from your SAML Identity Provider, as +# explained in: +# +# https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#saml +# +# You will need to modify these SAML settings: +SOCIAL_AUTH_SAML_ORG_INFO = { + "en-US": { + "displayname": "Example Inc.", + "name": "example", + "url": "%s%s" % ('https://', EXTERNAL_HOST), + } +} +SOCIAL_AUTH_SAML_ENABLED_IDPS = { + # The fields are explained in detail here: + # https://python-social-auth-docs.readthedocs.io/en/latest/backends/saml.html + "idp_name": { + # Configure entity_id and url according to information provided to you by your IdP: + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", + # The part below corresponds to what's likely referred to as something like + # "Attribute Statements" (with Okta as your IdP) or "Attribute Mapping" (with G Suite). + # The names on the right side need to correspond to the names under which + # the IdP will send the user attributes. With these defaults, it's expected + # that the user's email will be sent with the "email" attribute name, + # the first name and the last name with the "first_name", "last_name" attribute names. + "attr_user_permanent_id": "email", + "attr_first_name": "first_name", + "attr_last_name": "last_name", + "attr_username": "email", + "attr_email": "email", + # The "x509cert" attribute is automatically read from + # /etc/zulip/saml/idps/{idp_name}.crt; don't specify it here. + } +} + +SOCIAL_AUTH_SAML_SECURITY_CONFIG = { + # If you've set up the optional private and public server keys, + # set this to True to enable signing of SAMLRequests using the + # private key. + "authnRequestsSigned": False, +} + +# These SAML settings you likely won't need to modify. +SOCIAL_AUTH_SAML_SP_ENTITY_ID = 'https://' + EXTERNAL_HOST +SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = { + "givenName": "Technical team", + "emailAddress": ZULIP_ADMINISTRATOR, +} +SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { + "givenName": "Support team", + "emailAddress": ZULIP_ADMINISTRATOR, +} ######## # Azure Active Directory OAuth. @@ -211,7 +269,6 @@ AUTHENTICATION_BACKENDS = ( # SSO_APPEND_DOMAIN = "example.com") SSO_APPEND_DOMAIN = None # type: Optional[str] - ################ # Miscellaneous settings. diff --git a/zproject/settings.py b/zproject/settings.py index ed12f50312..ed3df8d07d 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -52,6 +52,13 @@ def get_config(section: str, key: str, default_value: Optional[Any]=None) -> Opt return config_file.get(section, key) return default_value +def get_from_file_if_exists(path: str) -> str: + if os.path.exists(path): + with open(path, "r") as f: + return f.read() + else: + return '' + # Make this unique, and don't share it with anybody. SECRET_KEY = get_secret("secret_key") @@ -160,6 +167,14 @@ DEFAULT_SETTINGS = { 'SOCIAL_AUTH_SUBDOMAIN': None, 'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET': get_secret('azure_oauth2_secret'), 'SOCIAL_AUTH_GOOGLE_KEY': get_secret('social_auth_google_key', development_only=True), + # SAML: + 'SOCIAL_AUTH_SAML_SP_ENTITY_ID': None, + 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT': '', + 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY': '', + 'SOCIAL_AUTH_SAML_ORG_INFO': None, + 'SOCIAL_AUTH_SAML_TECHNICAL_CONTACT': None, + 'SOCIAL_AUTH_SAML_SUPPORT_CONTACT': None, + 'SOCIAL_AUTH_SAML_ENABLED_IDPS': {}, # Historical name for SOCIAL_AUTH_GITHUB_KEY; still allowed in production. 'GOOGLE_OAUTH2_CLIENT_ID': None, @@ -1355,6 +1370,26 @@ GOOGLE_OAUTH2_CLIENT_SECRET = get_secret('google_oauth2_client_secret') SOCIAL_AUTH_GOOGLE_KEY = SOCIAL_AUTH_GOOGLE_KEY or GOOGLE_OAUTH2_CLIENT_ID SOCIAL_AUTH_GOOGLE_SECRET = SOCIAL_AUTH_GOOGLE_SECRET or GOOGLE_OAUTH2_CLIENT_SECRET +if PRODUCTION: + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = get_from_file_if_exists("/etc/zulip/saml/zulip-cert.crt") + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = get_from_file_if_exists("/etc/zulip/saml/zulip-private-key.key") + +for idp_name, idp_dict in SOCIAL_AUTH_SAML_ENABLED_IDPS.items(): + if DEVELOPMENT: + idp_dict['entity_id'] = get_secret('saml_entity_id', '') + idp_dict['url'] = get_secret('saml_url', '') + idp_dict['x509cert_path'] = 'zproject/dev_saml.cert' + + # Set `x509cert` if not specified already; also support an override path. + if 'x509cert' in idp_dict: + continue + + if 'x509cert_path' in idp_dict: + path = idp_dict['x509cert_path'] + else: + path = "/etc/zulip/saml/idps/{}.crt".format(idp_name) + idp_dict['x509cert'] = get_from_file_if_exists(path) + SOCIAL_AUTH_PIPELINE = [ 'social_core.pipeline.social_auth.social_details', 'zproject.backends.social_auth_associate_user', diff --git a/zproject/test_settings.py b/zproject/test_settings.py index 8ea2a373cb..2c8abd15de 100644 --- a/zproject/test_settings.py +++ b/zproject/test_settings.py @@ -172,3 +172,38 @@ THUMBOR_SERVES_CAMO = True # Logging the emails while running the tests adds them # to /emails page. DEVELOPMENT_LOG_EMAILS = False + +SOCIAL_AUTH_SAML_SP_ENTITY_ID = 'http://' + EXTERNAL_HOST +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = get_from_file_if_exists("zerver/tests/fixtures/saml/zulip.crt") +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = get_from_file_if_exists("zerver/tests/fixtures/saml/zulip.key") + +SOCIAL_AUTH_SAML_ORG_INFO = { + "en-US": { + "name": "example", + "displayname": "Example Inc.", + "url": "%s%s" % ('http://', EXTERNAL_HOST), + } +} + +SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = { + "givenName": "Tech Gal", + "emailAddress": "technical@example.com" +} + +SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { + "givenName": "Support Guy", + "emailAddress": "support@example.com", +} + +SOCIAL_AUTH_SAML_ENABLED_IDPS = { + "test_idp": { + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", + "x509cert": get_from_file_if_exists("zerver/tests/fixtures/saml/idp.crt"), + "attr_user_permanent_id": "email", + "attr_first_name": "first_name", + "attr_last_name": "last_name", + "attr_username": "email", + "attr_email": "email", + } +} diff --git a/zproject/urls.py b/zproject/urls.py index ed01abf2e2..5dc3db7e36 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -581,6 +581,9 @@ i18n_urls = [ template_name='zerver/config_error.html',), {'dev_not_supported_error': True}, name='dev_not_supported'), + url(r'^config-error/saml$', TemplateView.as_view( + template_name='zerver/config_error.html',), + {'saml_error': True},), ] # Make a copy of i18n_urls so that they appear without prefix for english @@ -709,6 +712,7 @@ urls += [ # Python Social Auth urls += [url(r'^', include('social_django.urls', namespace='social'))] +urls += [url(r'^saml/metadata.xml$', zerver.views.auth.saml_sp_metadata)] # User documentation site urls += [url(r'^help/(?P
.*)$',