saml: Add option to restrict subdomain access based on SAML attributes.

Adds the ability to set a SAML attribute which contains a
list of subdomains the user is allowed to access. This allows a Zulip
server with multiple organizations to filter using SAML attributes
which organization each user can access.

Cleaned up and adapted by Mateusz Mandera to fit our conventions and
needs more.

Co-authored-by: Mateusz Mandera <mateusz.mandera@zulip.com>
This commit is contained in:
Brainrecursion 2020-06-19 21:44:29 +02:00 committed by Tim Abbott
parent 2283a16476
commit 30eaed0378
5 changed files with 163 additions and 6 deletions

View File

@ -162,6 +162,34 @@ authenticate the user to when they visit your SSO URL from the IdP.
```eval_rst
.. _ldap:
```
### Restricting access to specific organizations
If you're hosting multiple Zulip organizations, you can restrict which
organizations can use a given IdP by setting `limit_to_subdomains`.
For example, `limit_to_subdomains = ["", "engineering"]` would
restrict an IdP the root domain and the `engineering` subdomain.
You can achieve the same goal with a SAML attribute; just declare
which attribute using `attr_org_membership` in the IdP configuration.
For the root subdomain, `www` in the list will work, or any other of
`settings.ROOT_SUBDOMAIN_ALIASES`.
For example, with `attr_org_membership` set to `member`, a user with
the following attribute in their `AttributeStatement` will have access
to the root and `engineering` subdomains:
```
<saml2:Attribute Name="member" 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">
www
</saml2:AttributeValue>
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">
engineering
</saml2:AttributeValue>
</saml2:Attribute>
```
## LDAP (including Active Directory)
Zulip supports retrieving information about users via LDAP, and

View File

@ -30,4 +30,4 @@ 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>
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>{extra_attrs}</saml2:AttributeStatement></saml2:Assertion></saml2p:Response>

View File

@ -6,7 +6,7 @@ import re
import time
import urllib
from contextlib import contextmanager
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
from unittest import mock
import jwt
@ -739,7 +739,12 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase):
params = {}
headers = {}
if subdomain is not None:
headers['HTTP_HOST'] = subdomain + ".testserver"
if subdomain == '':
# "testserver" may trip up some libraries' URL validation,
# so let's use the equivalent www. version.
headers['HTTP_HOST'] = 'www.testserver'
else:
headers['HTTP_HOST'] = subdomain + ".testserver"
if mobile_flow_otp is not None:
params['mobile_flow_otp'] = mobile_flow_otp
headers['HTTP_USER_AGENT'] = "ZulipAndroid"
@ -1458,6 +1463,7 @@ class SAMLAuthBackendTest(SocialAuthBase):
next: str='',
multiuse_object_key: str='',
user_agent: Optional[str]=None,
extra_attributes: Mapping[str, List[str]]={},
**extra_data: Any) -> HttpResponse:
url, headers = self.prepare_login_url_and_headers(
subdomain,
@ -1496,7 +1502,9 @@ class SAMLAuthBackendTest(SocialAuthBase):
if is_signup:
self.assertEqual(data['is_signup'], '1')
saml_response = self.generate_saml_response(**account_data_dict)
saml_response = self.generate_saml_response(email=account_data_dict['email'],
name=account_data_dict['name'],
extra_attributes=extra_attributes)
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
@ -1511,7 +1519,7 @@ class SAMLAuthBackendTest(SocialAuthBase):
return result
def generate_saml_response(self, email: str, name: str) -> str:
def generate_saml_response(self, email: str, name: str, extra_attributes: Mapping[str, List[str]]={}) -> str:
"""
The samlresponse.txt fixture has a pre-generated SAMLResponse,
with {email}, {first_name}, {last_name} placeholders, that can
@ -1521,10 +1529,22 @@ class SAMLAuthBackendTest(SocialAuthBase):
first_name = name_parts[0]
last_name = name_parts[1]
extra_attrs = ''
for extra_attr_name, extra_attr_values in extra_attributes.items():
values = ''.join(
['<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" ' +
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">' +
f'{value}</saml2:AttributeValue>' for value in extra_attr_values]
)
extra_attrs += f'<saml2:Attribute Name="{extra_attr_name}" ' + \
'NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">' + \
f'{values}</saml2:Attribute>'
unencoded_saml_response = self.fixture_data("samlresponse.txt", type="saml").format(
email=email,
first_name=first_name,
last_name=last_name,
extra_attrs=extra_attrs,
)
# SAMLResponse needs to be base64-encoded.
saml_response: str = base64.b64encode(unencoded_saml_response.encode()).decode()
@ -1934,6 +1954,72 @@ class SAMLAuthBackendTest(SocialAuthBase):
"info",
)])
def test_social_auth_saml_idp_org_membership_success(self) -> None:
idps_dict = copy.deepcopy(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS)
idps_dict['test_idp']['attr_org_membership'] = 'member'
with self.settings(SOCIAL_AUTH_SAML_ENABLED_IDPS=idps_dict):
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
result = self.social_auth_test(account_data_dict,
subdomain='zulip',
expect_choose_email_screen=False,
extra_attributes=dict(member=['zulip']))
data = load_subdomain_token(result)
self.assertEqual(data['email'], self.email)
self.assertEqual(data['full_name'], self.name)
self.assertEqual(data['subdomain'], 'zulip')
self.assertEqual(result.status_code, 302)
def test_social_auth_saml_idp_org_membership_root_subdomain(self) -> None:
realm = get_realm("zulip")
realm.string_id = ''
realm.save()
idps_dict = copy.deepcopy(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS)
idps_dict['test_idp']['attr_org_membership'] = 'member'
with self.settings(SOCIAL_AUTH_SAML_ENABLED_IDPS=idps_dict):
# Having one of the settings.ROOT_SUBDOMAIN_ALIASES in the membership attributes
# authorizes the user to access the root subdomain.
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
result = self.social_auth_test(account_data_dict,
subdomain='',
expect_choose_email_screen=False,
extra_attributes=dict(member=['www']))
data = load_subdomain_token(result)
self.assertEqual(data['email'], self.email)
self.assertEqual(data['full_name'], self.name)
self.assertEqual(data['subdomain'], '')
self.assertEqual(result.status_code, 302)
# Failure, the user doesn't have entitlements for the root subdomain.
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
with self.assertLogs(self.logger_string, level='INFO') as m:
result = self.social_auth_test(account_data_dict,
subdomain='',
expect_choose_email_screen=False,
extra_attributes=dict(member=['zephyr']))
self.assertEqual(result.status_code, 302)
self.assertEqual(m.output, [self.logger_output(
"AuthFailed: Authentication failed: SAML user from IdP test_idp rejected due to " +
"missing entitlement for subdomain ''. User entitlements: ['zephyr'].",
"info",
)])
def test_social_auth_saml_idp_org_membership_failed(self) -> None:
idps_dict = copy.deepcopy(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS)
idps_dict['test_idp']['attr_org_membership'] = 'member'
with self.settings(SOCIAL_AUTH_SAML_ENABLED_IDPS=idps_dict):
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
with self.assertLogs(self.logger_string, level='INFO') as m:
result = self.social_auth_test(account_data_dict, subdomain='zulip',
extra_attributes=dict(member=['zephyr', 'othersubdomain']))
self.assertEqual(result.status_code, 302)
self.assertEqual('/login/', result.url)
self.assertEqual(m.output, [self.logger_output(
"AuthFailed: Authentication failed: SAML user from IdP test_idp rejected due to " +
"missing entitlement for subdomain 'zulip'. User entitlements: ['zephyr', 'othersubdomain'].",
"info",
)])
class AppleAuthMixin:
BACKEND_CLASS = AppleAuthBackend
CLIENT_KEY_SETTING = "SOCIAL_AUTH_APPLE_KEY"

View File

@ -45,7 +45,7 @@ from social_core.backends.base import BaseAuth
from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, GithubTeamOAuth2
from social_core.backends.gitlab import GitLabOAuth2
from social_core.backends.google import GoogleOAuth2
from social_core.backends.saml import SAMLAuth
from social_core.backends.saml import SAMLAuth, SAMLIdentityProvider
from social_core.exceptions import (
AuthCanceled,
AuthFailed,
@ -1841,6 +1841,44 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
else:
return request_subdomain
def _check_entitlements(self, idp: SAMLIdentityProvider, attributes: Dict[str, List[str]]) -> None:
"""
Below is the docstring from the social_core SAML backend.
Additional verification of a SAML response before
authenticating the user.
Subclasses can override this method if they need custom
validation code, such as requiring the presence of an
eduPersonEntitlement.
raise social_core.exceptions.AuthForbidden if the user should not
be authenticated, or do nothing to allow the login pipeline to
continue.
"""
org_membership_attribute = idp.conf.get('attr_org_membership', None)
if org_membership_attribute is None:
return
subdomain = self.strategy.session_get('subdomain')
entitlements: Union[str, List[str]] = attributes.get(org_membership_attribute, [])
if subdomain in entitlements:
return
# The root subdomain is a special case, as sending an
# empty string in the list of values of the attribute may
# not be viable. So, any of the ROOT_SUBDOMAIN_ALIASES can
# be used to signify the user is authorized for the root
# subdomain.
if (subdomain == Realm.SUBDOMAIN_FOR_ROOT_DOMAIN
and not settings.ROOT_DOMAIN_LANDING_PAGE
and any(alias in entitlements for alias in settings.ROOT_SUBDOMAIN_ALIASES)):
return
error_msg = f"SAML user from IdP {idp.name} rejected due to missing entitlement " + \
f"for subdomain '{subdomain}'. User entitlements: {entitlements}."
raise AuthFailed(self, error_msg)
def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]:
"""
Additional ugly wrapping on top of auth_complete in SocialAuthMixin.

View File

@ -244,6 +244,7 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = {
"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.
@ -259,6 +260,10 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = {
# If you want this IdP to only be enabled for authentication
# to certain subdomains, uncomment and edit the setting below.
# "limit_to_subdomains": ["subdomain1", "subdomain2"],
# You can also limit subdomains by setting "attr_org_membership"
# to be a SAML attribute containing the allowed subdomains for a user.
# "attr_org_membership": "member",
},
}