mirror of https://github.com/zulip/zulip.git
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:
parent
2283a16476
commit
30eaed0378
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue