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.
This commit is contained in:
Mateusz Mandera 2019-09-29 06:32:56 +02:00 committed by Tim Abbott
parent 82f923c27a
commit 4dc3ed36c3
20 changed files with 807 additions and 9 deletions

View File

@ -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:
```

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -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%;

View File

@ -80,6 +80,19 @@
{% endif %}
{% endif %}
{% if saml_error %}
<p>
SAML authentication is either not enabled or misconfigured. Have a look at
our <a href="https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#SAML">setup guide</a>.
</p>
{% if development_environment %}
<p>
See also
the <a href="https://zulip.readthedocs.io/en/latest/subsystems/auth.html#saml">SAML
guide</a> for the development environment.
</p>
{% endif %}
{% endif %}
<p>After making your changes, remember to restart
the Zulip server.</p>
</div>

View File

@ -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),
),
]

View File

@ -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

21
zerver/tests/fixtures/saml/idp.crt vendored Normal file
View File

@ -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-----

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?><saml2p:Response Destination="http://zulip.testserver/complete/saml/" ID="id544612569720442296425226" InResponseTo="ONELOGIN_a5fde8b09598814d7af2537f865d31c2f7aea831" IssueInstant="2019-09-25T01:02:02.120Z" Version="2.0" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exk1da4osrIL3Y7ip357</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id544612569720442296425226"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces PrefixList="xs" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>P/e+Gdz179UAcrrPZW2R9hzxMlSAGwbZ+Ogksp7Rzlg=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>H1eepG122h3jzIqorofI6sr636xVFFtqsN0Vj5eb9YoFN3KMDH1AqzvGbzA+XEoT/1vle/D2n1A0qMv6UrnMy0EgrZlA+Mx3MgcQDhFoIqI7lV48I2aJ+G1+FvTrzt1hhfn6SBTorhc3M2+ST9z68V8mLsNXr82GveL/Ej5J4rxbQQ0Jxaic3luAkV0EhROqiSDwC7e/45II34e3sdtQ9bbnf3feDbovklb7Daa/NIqWpWX+0Y9qhHo1zx05oPiGZFtveJHiUbFXPpjR0r1juuG3HTGkORhRHCMYnpz73NsmuBTkAgYE+G0vUr0k5Sk28efS15ZZuAyiN+XCjl6SzQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>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==</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2p:Status xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"><saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2p:Status><saml2:Assertion ID="id54461256972849811250394622" IssueInstant="2019-09-25T01:02:02.120Z" Version="2.0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xs="http://www.w3.org/2001/XMLSchema"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exk1da4osrIL3Y7ip357</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id54461256972849811250394622"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces PrefixList="xs" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>J5+QPDVShpm1VpG+dAjbU5GNrRwkEVMQ3MthoFukiH4=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RWvjlS6nr7MUm6JzSn71nD3nkM77bja8Mnfy8GZYL0tQwtLBdixgW+oUF8jSXId9/dkYKCcq1n3fxzyX1iMkAeF7YcUlmpfN56hRKECzdWmaaceCS7s15vTqN/Gy83AFWp6d/nBbyt25UnGtmOSJjU5+QmBBB/JJO2EjtCiJZlJkJy3V1nOU/PnJ5p3iutUNtn17gqVYKkWix8b95xdoOHEZLC/0w8pt6OOLePlg+HafCg0XA7jS3g4+vPagcAEhSBIEpX9rZVdaWZdpP5NxHbjtyG979n5tzx7ooVBebrEfPdneoQeZQabNU/jUeeWXBJNaQ3Rv59EidOaTI68LZA==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>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==</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">{email}</saml2:NameID><saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml2:SubjectConfirmationData InResponseTo="ONELOGIN_a5fde8b09598814d7af2537f865d31c2f7aea831" NotOnOrAfter="2019-09-25T01:07:02.120Z" Recipient="http://zulip.testserver/complete/saml/"/></saml2:SubjectConfirmation></saml2:Subject><saml2:Conditions NotBefore="2019-09-25T00:57:02.120Z" NotOnOrAfter="2019-09-25T01:07:02.120Z" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AudienceRestriction><saml2:Audience>http://zulip.testserver</saml2:Audience></saml2:AudienceRestriction></saml2:Conditions><saml2:AuthnStatement AuthnInstant="2019-09-25T01:01:37.741Z" SessionIndex="ONELOGIN_a5fde8b09598814d7af2537f865d31c2f7aea831" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AuthnContext><saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef></saml2:AuthnContext></saml2:AuthnStatement><saml2:AttributeStatement xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:Attribute Name="first_name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">{first_name}</saml2:AttributeValue></saml2:Attribute><saml2:Attribute Name="last_name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">{last_name}</saml2:AttributeValue></saml2:Attribute><saml2:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">{email}</saml2:AttributeValue></saml2:Attribute></saml2:AttributeStatement></saml2:Assertion></saml2p:Response>

21
zerver/tests/fixtures/saml/zulip.crt vendored Normal file
View File

@ -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-----

28
zerver/tests/fixtures/saml/zulip.key vendored Normal file
View File

@ -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-----

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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,

View File

@ -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/"

View File

@ -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.

View File

@ -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',

View File

@ -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",
}
}

View File

@ -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<article>.*)$',