Fix a regression when grandfathering SAML users. (#8855)

This was broken in #8801 when abstracting code shared with OIDC.

After this change both SAML and OIDC have a concept of
grandfathering users, but with different implementations.
This commit is contained in:
Patrick Cloke 2020-12-02 07:45:42 -05:00 committed by GitHub
parent c21bdc813f
commit 8388384a64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 94 additions and 48 deletions

1
changelog.d/8855.feature Normal file
View File

@ -0,0 +1 @@
Add support for re-trying generation of a localpart for OpenID Connect mapping providers.

View File

@ -39,7 +39,7 @@ from synapse.handlers._base import BaseHandler
from synapse.handlers.sso import MappingException, UserAttributes from synapse.handlers.sso import MappingException, UserAttributes
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable from synapse.logging.context import make_deferred_yieldable
from synapse.types import JsonDict, map_username_to_mxid_localpart from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
from synapse.util import json_decoder from synapse.util import json_decoder
if TYPE_CHECKING: if TYPE_CHECKING:
@ -898,13 +898,39 @@ class OidcHandler(BaseHandler):
return UserAttributes(**attributes) return UserAttributes(**attributes)
async def grandfather_existing_users() -> Optional[str]:
if self._allow_existing_users:
# If allowing existing users we want to generate a single localpart
# and attempt to match it.
attributes = await oidc_response_to_user_attributes(failures=0)
user_id = UserID(attributes.localpart, self.server_name).to_string()
users = await self.store.get_users_by_id_case_insensitive(user_id)
if users:
# If an existing matrix ID is returned, then use it.
if len(users) == 1:
previously_registered_user_id = next(iter(users))
elif user_id in users:
previously_registered_user_id = user_id
else:
# Do not attempt to continue generating Matrix IDs.
raise MappingException(
"Attempted to login as '{}' but it matches more than one user inexactly: {}".format(
user_id, users
)
)
return previously_registered_user_id
return None
return await self._sso_handler.get_mxid_from_sso( return await self._sso_handler.get_mxid_from_sso(
self._auth_provider_id, self._auth_provider_id,
remote_user_id, remote_user_id,
user_agent, user_agent,
ip_address, ip_address,
oidc_response_to_user_attributes, oidc_response_to_user_attributes,
self._allow_existing_users, grandfather_existing_users,
) )

View File

@ -268,7 +268,7 @@ class SamlHandler(BaseHandler):
emails=result.get("emails", []), emails=result.get("emails", []),
) )
with (await self._mapping_lock.queue(self._auth_provider_id)): async def grandfather_existing_users() -> Optional[str]:
# backwards-compatibility hack: see if there is an existing user with a # backwards-compatibility hack: see if there is an existing user with a
# suitable mapping from the uid # suitable mapping from the uid
if ( if (
@ -290,17 +290,18 @@ class SamlHandler(BaseHandler):
if users: if users:
registered_user_id = list(users.keys())[0] registered_user_id = list(users.keys())[0]
logger.info("Grandfathering mapping to %s", registered_user_id) logger.info("Grandfathering mapping to %s", registered_user_id)
await self.store.record_user_external_id(
self._auth_provider_id, remote_user_id, registered_user_id
)
return registered_user_id return registered_user_id
return None
with (await self._mapping_lock.queue(self._auth_provider_id)):
return await self._sso_handler.get_mxid_from_sso( return await self._sso_handler.get_mxid_from_sso(
self._auth_provider_id, self._auth_provider_id,
remote_user_id, remote_user_id,
user_agent, user_agent,
ip_address, ip_address,
saml_response_to_remapped_user_attributes, saml_response_to_remapped_user_attributes,
grandfather_existing_users,
) )
def expire_sessions(self): def expire_sessions(self):

View File

@ -116,7 +116,7 @@ class SsoHandler(BaseHandler):
user_agent: str, user_agent: str,
ip_address: str, ip_address: str,
sso_to_matrix_id_mapper: Callable[[int], Awaitable[UserAttributes]], sso_to_matrix_id_mapper: Callable[[int], Awaitable[UserAttributes]],
allow_existing_users: bool = False, grandfather_existing_users: Optional[Callable[[], Awaitable[Optional[str]]]],
) -> str: ) -> str:
""" """
Given an SSO ID, retrieve the user ID for it and possibly register the user. Given an SSO ID, retrieve the user ID for it and possibly register the user.
@ -125,6 +125,10 @@ class SsoHandler(BaseHandler):
if it has that matrix ID is returned regardless of the current mapping if it has that matrix ID is returned regardless of the current mapping
logic. logic.
If a callable is provided for grandfathering users, it is called and can
potentially return a matrix ID to use. If it does, the SSO ID is linked to
this matrix ID for subsequent calls.
The mapping function is called (potentially multiple times) to generate The mapping function is called (potentially multiple times) to generate
a localpart for the user. a localpart for the user.
@ -132,17 +136,6 @@ class SsoHandler(BaseHandler):
given user-agent and IP address and the SSO ID is linked to this matrix given user-agent and IP address and the SSO ID is linked to this matrix
ID for subsequent calls. ID for subsequent calls.
If allow_existing_users is true the mapping function is only called once
and results in:
1. The use of a previously registered matrix ID. In this case, the
SSO ID is linked to the matrix ID. (Note it is possible that
other SSO IDs are linked to the same matrix ID.)
2. An unused localpart, in which case the user is registered (as
discussed above).
3. An error if the generated localpart matches multiple pre-existing
matrix IDs. Generally this should not happen.
Args: Args:
auth_provider_id: A unique identifier for this SSO provider, e.g. auth_provider_id: A unique identifier for this SSO provider, e.g.
"oidc" or "saml". "oidc" or "saml".
@ -152,8 +145,9 @@ class SsoHandler(BaseHandler):
sso_to_matrix_id_mapper: A callable to generate the user attributes. sso_to_matrix_id_mapper: A callable to generate the user attributes.
The only parameter is an integer which represents the amount of The only parameter is an integer which represents the amount of
times the returned mxid localpart mapping has failed. times the returned mxid localpart mapping has failed.
allow_existing_users: True if the localpart returned from the grandfather_existing_users: A callable which can return an previously
mapping provider can be linked to an existing matrix ID. existing matrix ID. The SSO ID is then linked to the returned
matrix ID.
Returns: Returns:
The user ID associated with the SSO response. The user ID associated with the SSO response.
@ -171,6 +165,16 @@ class SsoHandler(BaseHandler):
if previously_registered_user_id: if previously_registered_user_id:
return previously_registered_user_id return previously_registered_user_id
# Check for grandfathering of users.
if grandfather_existing_users:
previously_registered_user_id = await grandfather_existing_users()
if previously_registered_user_id:
# Future logins should also match this user ID.
await self.store.record_user_external_id(
auth_provider_id, remote_user_id, previously_registered_user_id
)
return previously_registered_user_id
# Otherwise, generate a new user. # Otherwise, generate a new user.
for i in range(self._MAP_USERNAME_RETRIES): for i in range(self._MAP_USERNAME_RETRIES):
try: try:
@ -194,33 +198,7 @@ class SsoHandler(BaseHandler):
# Check if this mxid already exists # Check if this mxid already exists
user_id = UserID(attributes.localpart, self.server_name).to_string() user_id = UserID(attributes.localpart, self.server_name).to_string()
users = await self.store.get_users_by_id_case_insensitive(user_id) if not await self.store.get_users_by_id_case_insensitive(user_id):
# Note, if allow_existing_users is true then the loop is guaranteed
# to end on the first iteration: either by matching an existing user,
# raising an error, or registering a new user. See the docstring for
# more in-depth an explanation.
if users and allow_existing_users:
# If an existing matrix ID is returned, then use it.
if len(users) == 1:
previously_registered_user_id = next(iter(users))
elif user_id in users:
previously_registered_user_id = user_id
else:
# Do not attempt to continue generating Matrix IDs.
raise MappingException(
"Attempted to login as '{}' but it matches more than one user inexactly: {}".format(
user_id, users
)
)
# Future logins should also match this user ID.
await self.store.record_user_external_id(
auth_provider_id, remote_user_id, previously_registered_user_id
)
return previously_registered_user_id
elif not users:
# This mxid is free # This mxid is free
break break
else: else:

View File

@ -731,6 +731,14 @@ class OidcHandlerTestCase(HomeserverTestCase):
) )
self.assertEqual(mxid, "@test_user:test") self.assertEqual(mxid, "@test_user:test")
# Subsequent calls should map to the same mxid.
mxid = self.get_success(
self.handler._map_userinfo_to_user(
userinfo, token, "user-agent", "10.10.10.10"
)
)
self.assertEqual(mxid, "@test_user:test")
# Note that a second SSO user can be mapped to the same Matrix ID. (This # Note that a second SSO user can be mapped to the same Matrix ID. (This
# requires a unique sub, but something that maps to the same matrix ID, # requires a unique sub, but something that maps to the same matrix ID,
# in this case we'll just use the same username. A more realistic example # in this case we'll just use the same username. A more realistic example

View File

@ -16,7 +16,7 @@ import attr
from synapse.handlers.sso import MappingException from synapse.handlers.sso import MappingException
from tests.unittest import HomeserverTestCase from tests.unittest import HomeserverTestCase, override_config
# These are a few constants that are used as config parameters in the tests. # These are a few constants that are used as config parameters in the tests.
BASE_URL = "https://synapse/" BASE_URL = "https://synapse/"
@ -59,6 +59,10 @@ class SamlHandlerTestCase(HomeserverTestCase):
"grandfathered_mxid_source_attribute": None, "grandfathered_mxid_source_attribute": None,
"user_mapping_provider": {"module": __name__ + ".TestMappingProvider"}, "user_mapping_provider": {"module": __name__ + ".TestMappingProvider"},
} }
# Update this config with what's in the default config so that
# override_config works as expected.
saml_config.update(config.get("saml2_config", {}))
config["saml2_config"] = saml_config config["saml2_config"] = saml_config
return config return config
@ -86,6 +90,34 @@ class SamlHandlerTestCase(HomeserverTestCase):
) )
self.assertEqual(mxid, "@test_user:test") self.assertEqual(mxid, "@test_user:test")
@override_config({"saml2_config": {"grandfathered_mxid_source_attribute": "mxid"}})
def test_map_saml_response_to_existing_user(self):
"""Existing users can log in with SAML account."""
store = self.hs.get_datastore()
self.get_success(
store.register_user(user_id="@test_user:test", password_hash=None)
)
# Map a user via SSO.
saml_response = FakeAuthnResponse(
{"uid": "tester", "mxid": ["test_user"], "username": "test_user"}
)
redirect_url = ""
mxid = self.get_success(
self.handler._map_saml_response_to_user(
saml_response, redirect_url, "user-agent", "10.10.10.10"
)
)
self.assertEqual(mxid, "@test_user:test")
# Subsequent calls should map to the same mxid.
mxid = self.get_success(
self.handler._map_saml_response_to_user(
saml_response, redirect_url, "user-agent", "10.10.10.10"
)
)
self.assertEqual(mxid, "@test_user:test")
def test_map_saml_response_to_invalid_localpart(self): def test_map_saml_response_to_invalid_localpart(self):
"""If the mapping provider generates an invalid localpart it should be rejected.""" """If the mapping provider generates an invalid localpart it should be rejected."""
saml_response = FakeAuthnResponse({"uid": "test", "username": "föö"}) saml_response = FakeAuthnResponse({"uid": "test", "username": "föö"})