Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 8388384

Browse files
authored
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.
1 parent c21bdc8 commit 8388384

File tree

6 files changed

+94
-48
lines changed

6 files changed

+94
-48
lines changed

changelog.d/8855.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for re-trying generation of a localpart for OpenID Connect mapping providers.

synapse/handlers/oidc_handler.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from synapse.handlers.sso import MappingException, UserAttributes
4040
from synapse.http.site import SynapseRequest
4141
from synapse.logging.context import make_deferred_yieldable
42-
from synapse.types import JsonDict, map_username_to_mxid_localpart
42+
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
4343
from synapse.util import json_decoder
4444

4545
if TYPE_CHECKING:
@@ -898,13 +898,39 @@ async def oidc_response_to_user_attributes(failures: int) -> UserAttributes:
898898

899899
return UserAttributes(**attributes)
900900

901+
async def grandfather_existing_users() -> Optional[str]:
902+
if self._allow_existing_users:
903+
# If allowing existing users we want to generate a single localpart
904+
# and attempt to match it.
905+
attributes = await oidc_response_to_user_attributes(failures=0)
906+
907+
user_id = UserID(attributes.localpart, self.server_name).to_string()
908+
users = await self.store.get_users_by_id_case_insensitive(user_id)
909+
if users:
910+
# If an existing matrix ID is returned, then use it.
911+
if len(users) == 1:
912+
previously_registered_user_id = next(iter(users))
913+
elif user_id in users:
914+
previously_registered_user_id = user_id
915+
else:
916+
# Do not attempt to continue generating Matrix IDs.
917+
raise MappingException(
918+
"Attempted to login as '{}' but it matches more than one user inexactly: {}".format(
919+
user_id, users
920+
)
921+
)
922+
923+
return previously_registered_user_id
924+
925+
return None
926+
901927
return await self._sso_handler.get_mxid_from_sso(
902928
self._auth_provider_id,
903929
remote_user_id,
904930
user_agent,
905931
ip_address,
906932
oidc_response_to_user_attributes,
907-
self._allow_existing_users,
933+
grandfather_existing_users,
908934
)
909935

910936

synapse/handlers/saml_handler.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ async def saml_response_to_remapped_user_attributes(
268268
emails=result.get("emails", []),
269269
)
270270

271-
with (await self._mapping_lock.queue(self._auth_provider_id)):
271+
async def grandfather_existing_users() -> Optional[str]:
272272
# backwards-compatibility hack: see if there is an existing user with a
273273
# suitable mapping from the uid
274274
if (
@@ -290,17 +290,18 @@ async def saml_response_to_remapped_user_attributes(
290290
if users:
291291
registered_user_id = list(users.keys())[0]
292292
logger.info("Grandfathering mapping to %s", registered_user_id)
293-
await self.store.record_user_external_id(
294-
self._auth_provider_id, remote_user_id, registered_user_id
295-
)
296293
return registered_user_id
297294

295+
return None
296+
297+
with (await self._mapping_lock.queue(self._auth_provider_id)):
298298
return await self._sso_handler.get_mxid_from_sso(
299299
self._auth_provider_id,
300300
remote_user_id,
301301
user_agent,
302302
ip_address,
303303
saml_response_to_remapped_user_attributes,
304+
grandfather_existing_users,
304305
)
305306

306307
def expire_sessions(self):

synapse/handlers/sso.py

Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ async def get_mxid_from_sso(
116116
user_agent: str,
117117
ip_address: str,
118118
sso_to_matrix_id_mapper: Callable[[int], Awaitable[UserAttributes]],
119-
allow_existing_users: bool = False,
119+
grandfather_existing_users: Optional[Callable[[], Awaitable[Optional[str]]]],
120120
) -> str:
121121
"""
122122
Given an SSO ID, retrieve the user ID for it and possibly register the user.
@@ -125,24 +125,17 @@ async def get_mxid_from_sso(
125125
if it has that matrix ID is returned regardless of the current mapping
126126
logic.
127127
128+
If a callable is provided for grandfathering users, it is called and can
129+
potentially return a matrix ID to use. If it does, the SSO ID is linked to
130+
this matrix ID for subsequent calls.
131+
128132
The mapping function is called (potentially multiple times) to generate
129133
a localpart for the user.
130134
131135
If an unused localpart is generated, the user is registered from the
132136
given user-agent and IP address and the SSO ID is linked to this matrix
133137
ID for subsequent calls.
134138
135-
If allow_existing_users is true the mapping function is only called once
136-
and results in:
137-
138-
1. The use of a previously registered matrix ID. In this case, the
139-
SSO ID is linked to the matrix ID. (Note it is possible that
140-
other SSO IDs are linked to the same matrix ID.)
141-
2. An unused localpart, in which case the user is registered (as
142-
discussed above).
143-
3. An error if the generated localpart matches multiple pre-existing
144-
matrix IDs. Generally this should not happen.
145-
146139
Args:
147140
auth_provider_id: A unique identifier for this SSO provider, e.g.
148141
"oidc" or "saml".
@@ -152,8 +145,9 @@ async def get_mxid_from_sso(
152145
sso_to_matrix_id_mapper: A callable to generate the user attributes.
153146
The only parameter is an integer which represents the amount of
154147
times the returned mxid localpart mapping has failed.
155-
allow_existing_users: True if the localpart returned from the
156-
mapping provider can be linked to an existing matrix ID.
148+
grandfather_existing_users: A callable which can return an previously
149+
existing matrix ID. The SSO ID is then linked to the returned
150+
matrix ID.
157151
158152
Returns:
159153
The user ID associated with the SSO response.
@@ -171,6 +165,16 @@ async def get_mxid_from_sso(
171165
if previously_registered_user_id:
172166
return previously_registered_user_id
173167

168+
# Check for grandfathering of users.
169+
if grandfather_existing_users:
170+
previously_registered_user_id = await grandfather_existing_users()
171+
if previously_registered_user_id:
172+
# Future logins should also match this user ID.
173+
await self.store.record_user_external_id(
174+
auth_provider_id, remote_user_id, previously_registered_user_id
175+
)
176+
return previously_registered_user_id
177+
174178
# Otherwise, generate a new user.
175179
for i in range(self._MAP_USERNAME_RETRIES):
176180
try:
@@ -194,33 +198,7 @@ async def get_mxid_from_sso(
194198

195199
# Check if this mxid already exists
196200
user_id = UserID(attributes.localpart, self.server_name).to_string()
197-
users = await self.store.get_users_by_id_case_insensitive(user_id)
198-
# Note, if allow_existing_users is true then the loop is guaranteed
199-
# to end on the first iteration: either by matching an existing user,
200-
# raising an error, or registering a new user. See the docstring for
201-
# more in-depth an explanation.
202-
if users and allow_existing_users:
203-
# If an existing matrix ID is returned, then use it.
204-
if len(users) == 1:
205-
previously_registered_user_id = next(iter(users))
206-
elif user_id in users:
207-
previously_registered_user_id = user_id
208-
else:
209-
# Do not attempt to continue generating Matrix IDs.
210-
raise MappingException(
211-
"Attempted to login as '{}' but it matches more than one user inexactly: {}".format(
212-
user_id, users
213-
)
214-
)
215-
216-
# Future logins should also match this user ID.
217-
await self.store.record_user_external_id(
218-
auth_provider_id, remote_user_id, previously_registered_user_id
219-
)
220-
221-
return previously_registered_user_id
222-
223-
elif not users:
201+
if not await self.store.get_users_by_id_case_insensitive(user_id):
224202
# This mxid is free
225203
break
226204
else:

tests/handlers/test_oidc.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,14 @@ def test_map_userinfo_to_existing_user(self):
731731
)
732732
self.assertEqual(mxid, "@test_user:test")
733733

734+
# Subsequent calls should map to the same mxid.
735+
mxid = self.get_success(
736+
self.handler._map_userinfo_to_user(
737+
userinfo, token, "user-agent", "10.10.10.10"
738+
)
739+
)
740+
self.assertEqual(mxid, "@test_user:test")
741+
734742
# Note that a second SSO user can be mapped to the same Matrix ID. (This
735743
# requires a unique sub, but something that maps to the same matrix ID,
736744
# in this case we'll just use the same username. A more realistic example

tests/handlers/test_saml.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from synapse.handlers.sso import MappingException
1818

19-
from tests.unittest import HomeserverTestCase
19+
from tests.unittest import HomeserverTestCase, override_config
2020

2121
# These are a few constants that are used as config parameters in the tests.
2222
BASE_URL = "https://synapse/"
@@ -59,6 +59,10 @@ def default_config(self):
5959
"grandfathered_mxid_source_attribute": None,
6060
"user_mapping_provider": {"module": __name__ + ".TestMappingProvider"},
6161
}
62+
63+
# Update this config with what's in the default config so that
64+
# override_config works as expected.
65+
saml_config.update(config.get("saml2_config", {}))
6266
config["saml2_config"] = saml_config
6367

6468
return config
@@ -86,6 +90,34 @@ def test_map_saml_response_to_user(self):
8690
)
8791
self.assertEqual(mxid, "@test_user:test")
8892

93+
@override_config({"saml2_config": {"grandfathered_mxid_source_attribute": "mxid"}})
94+
def test_map_saml_response_to_existing_user(self):
95+
"""Existing users can log in with SAML account."""
96+
store = self.hs.get_datastore()
97+
self.get_success(
98+
store.register_user(user_id="@test_user:test", password_hash=None)
99+
)
100+
101+
# Map a user via SSO.
102+
saml_response = FakeAuthnResponse(
103+
{"uid": "tester", "mxid": ["test_user"], "username": "test_user"}
104+
)
105+
redirect_url = ""
106+
mxid = self.get_success(
107+
self.handler._map_saml_response_to_user(
108+
saml_response, redirect_url, "user-agent", "10.10.10.10"
109+
)
110+
)
111+
self.assertEqual(mxid, "@test_user:test")
112+
113+
# Subsequent calls should map to the same mxid.
114+
mxid = self.get_success(
115+
self.handler._map_saml_response_to_user(
116+
saml_response, redirect_url, "user-agent", "10.10.10.10"
117+
)
118+
)
119+
self.assertEqual(mxid, "@test_user:test")
120+
89121
def test_map_saml_response_to_invalid_localpart(self):
90122
"""If the mapping provider generates an invalid localpart it should be rejected."""
91123
saml_response = FakeAuthnResponse({"uid": "test", "username": "föö"})

0 commit comments

Comments
 (0)