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

Commit c36cf3c

Browse files
committed
Merge pull request #6037 from matrix-org/rav/saml_mapping_work
2 parents 6d09abc + 4f6bbe9 commit c36cf3c

File tree

7 files changed

+280
-10
lines changed

7 files changed

+280
-10
lines changed

changelog.d/6037.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make the process for mapping SAML2 users to matrix IDs more flexible.

docs/sample_config.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,32 @@ saml2_config:
13311331
#
13321332
#saml_session_lifetime: 5m
13331333

1334+
# The SAML attribute (after mapping via the attribute maps) to use to derive
1335+
# the Matrix ID from. 'uid' by default.
1336+
#
1337+
#mxid_source_attribute: displayName
1338+
1339+
# The mapping system to use for mapping the saml attribute onto a matrix ID.
1340+
# Options include:
1341+
# * 'hexencode' (which maps unpermitted characters to '=xx')
1342+
# * 'dotreplace' (which replaces unpermitted characters with '.').
1343+
# The default is 'hexencode'.
1344+
#
1345+
#mxid_mapping: dotreplace
1346+
1347+
# In previous versions of synapse, the mapping from SAML attribute to MXID was
1348+
# always calculated dynamically rather than stored in a table. For backwards-
1349+
# compatibility, we will look for user_ids matching such a pattern before
1350+
# creating a new account.
1351+
#
1352+
# This setting controls the SAML attribute which will be used for this
1353+
# backwards-compatibility lookup. Typically it should be 'uid', but if the
1354+
# attribute maps are changed, it may be necessary to change it.
1355+
#
1356+
# The default is 'uid'.
1357+
#
1358+
#grandfathered_mxid_source_attribute: upn
1359+
13341360

13351361

13361362
# Enable CAS for registration and login.

synapse/config/saml2_config.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17+
import re
18+
1719
from synapse.python_dependencies import DependencyException, check_requirements
20+
from synapse.types import (
21+
map_username_to_mxid_localpart,
22+
mxid_localpart_allowed_characters,
23+
)
1824
from synapse.util.module_loader import load_python_module
1925

2026
from ._base import Config, ConfigError
@@ -67,6 +73,14 @@ def read_config(self, config, **kwargs):
6773

6874
self.saml2_enabled = True
6975

76+
self.saml2_mxid_source_attribute = saml2_config.get(
77+
"mxid_source_attribute", "uid"
78+
)
79+
80+
self.saml2_grandfathered_mxid_source_attribute = saml2_config.get(
81+
"grandfathered_mxid_source_attribute", "uid"
82+
)
83+
7084
saml2_config_dict = self._default_saml_config_dict()
7185
_dict_merge(
7286
merge_dict=saml2_config.get("sp_config", {}), into_dict=saml2_config_dict
@@ -87,13 +101,26 @@ def read_config(self, config, **kwargs):
87101
saml2_config.get("saml_session_lifetime", "5m")
88102
)
89103

104+
mapping = saml2_config.get("mxid_mapping", "hexencode")
105+
try:
106+
self.saml2_mxid_mapper = MXID_MAPPER_MAP[mapping]
107+
except KeyError:
108+
raise ConfigError("%s is not a known mxid_mapping" % (mapping,))
109+
90110
def _default_saml_config_dict(self):
91111
import saml2
92112

93113
public_baseurl = self.public_baseurl
94114
if public_baseurl is None:
95115
raise ConfigError("saml2_config requires a public_baseurl to be set")
96116

117+
required_attributes = {"uid", self.saml2_mxid_source_attribute}
118+
119+
optional_attributes = {"displayName"}
120+
if self.saml2_grandfathered_mxid_source_attribute:
121+
optional_attributes.add(self.saml2_grandfathered_mxid_source_attribute)
122+
optional_attributes -= required_attributes
123+
97124
metadata_url = public_baseurl + "_matrix/saml2/metadata.xml"
98125
response_url = public_baseurl + "_matrix/saml2/authn_response"
99126
return {
@@ -105,8 +132,9 @@ def _default_saml_config_dict(self):
105132
(response_url, saml2.BINDING_HTTP_POST)
106133
]
107134
},
108-
"required_attributes": ["uid"],
109-
"optional_attributes": ["mail", "surname", "givenname"],
135+
"required_attributes": list(required_attributes),
136+
"optional_attributes": list(optional_attributes),
137+
# "name_id_format": saml2.saml.NAMEID_FORMAT_PERSISTENT,
110138
}
111139
},
112140
}
@@ -182,6 +210,52 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
182210
# The default is 5 minutes.
183211
#
184212
#saml_session_lifetime: 5m
213+
214+
# The SAML attribute (after mapping via the attribute maps) to use to derive
215+
# the Matrix ID from. 'uid' by default.
216+
#
217+
#mxid_source_attribute: displayName
218+
219+
# The mapping system to use for mapping the saml attribute onto a matrix ID.
220+
# Options include:
221+
# * 'hexencode' (which maps unpermitted characters to '=xx')
222+
# * 'dotreplace' (which replaces unpermitted characters with '.').
223+
# The default is 'hexencode'.
224+
#
225+
#mxid_mapping: dotreplace
226+
227+
# In previous versions of synapse, the mapping from SAML attribute to MXID was
228+
# always calculated dynamically rather than stored in a table. For backwards-
229+
# compatibility, we will look for user_ids matching such a pattern before
230+
# creating a new account.
231+
#
232+
# This setting controls the SAML attribute which will be used for this
233+
# backwards-compatibility lookup. Typically it should be 'uid', but if the
234+
# attribute maps are changed, it may be necessary to change it.
235+
#
236+
# The default is 'uid'.
237+
#
238+
#grandfathered_mxid_source_attribute: upn
185239
""" % {
186240
"config_dir_path": config_dir_path
187241
}
242+
243+
244+
DOT_REPLACE_PATTERN = re.compile(
245+
("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),))
246+
)
247+
248+
249+
def dot_replace_for_mxid(username: str) -> str:
250+
username = username.lower()
251+
username = DOT_REPLACE_PATTERN.sub(".", username)
252+
253+
# regular mxids aren't allowed to start with an underscore either
254+
username = re.sub("^_", "", username)
255+
return username
256+
257+
258+
MXID_MAPPER_MAP = {
259+
"hexencode": map_username_to_mxid_localpart,
260+
"dotreplace": dot_replace_for_mxid,
261+
}

synapse/handlers/saml_handler.py

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from synapse.api.errors import SynapseError
2222
from synapse.http.servlet import parse_string
2323
from synapse.rest.client.v1.login import SSOAuthHandler
24+
from synapse.types import UserID, map_username_to_mxid_localpart
25+
from synapse.util.async_helpers import Linearizer
2426

2527
logger = logging.getLogger(__name__)
2628

@@ -29,12 +31,26 @@ class SamlHandler:
2931
def __init__(self, hs):
3032
self._saml_client = Saml2Client(hs.config.saml2_sp_config)
3133
self._sso_auth_handler = SSOAuthHandler(hs)
34+
self._registration_handler = hs.get_registration_handler()
35+
36+
self._clock = hs.get_clock()
37+
self._datastore = hs.get_datastore()
38+
self._hostname = hs.hostname
39+
self._saml2_session_lifetime = hs.config.saml2_session_lifetime
40+
self._mxid_source_attribute = hs.config.saml2_mxid_source_attribute
41+
self._grandfathered_mxid_source_attribute = (
42+
hs.config.saml2_grandfathered_mxid_source_attribute
43+
)
44+
self._mxid_mapper = hs.config.saml2_mxid_mapper
45+
46+
# identifier for the external_ids table
47+
self._auth_provider_id = "saml"
3248

3349
# a map from saml session id to Saml2SessionData object
3450
self._outstanding_requests_dict = {}
3551

36-
self._clock = hs.get_clock()
37-
self._saml2_session_lifetime = hs.config.saml2_session_lifetime
52+
# a lock on the mappings
53+
self._mapping_lock = Linearizer(name="saml_mapping", clock=self._clock)
3854

3955
def handle_redirect_request(self, client_redirect_url):
4056
"""Handle an incoming request to /login/sso/redirect
@@ -60,7 +76,7 @@ def handle_redirect_request(self, client_redirect_url):
6076
# this shouldn't happen!
6177
raise Exception("prepare_for_authenticate didn't return a Location header")
6278

63-
def handle_saml_response(self, request):
79+
async def handle_saml_response(self, request):
6480
"""Handle an incoming request to /_matrix/saml2/authn_response
6581
6682
Args:
@@ -77,6 +93,10 @@ def handle_saml_response(self, request):
7793
# the dict.
7894
self.expire_sessions()
7995

96+
user_id = await self._map_saml_response_to_user(resp_bytes)
97+
self._sso_auth_handler.complete_sso_login(user_id, request, relay_state)
98+
99+
async def _map_saml_response_to_user(self, resp_bytes):
80100
try:
81101
saml2_auth = self._saml_client.parse_authn_request_response(
82102
resp_bytes,
@@ -91,18 +111,88 @@ def handle_saml_response(self, request):
91111
logger.warning("SAML2 response was not signed")
92112
raise SynapseError(400, "SAML2 response was not signed")
93113

94-
if "uid" not in saml2_auth.ava:
114+
logger.info("SAML2 response: %s", saml2_auth.origxml)
115+
logger.info("SAML2 mapped attributes: %s", saml2_auth.ava)
116+
117+
try:
118+
remote_user_id = saml2_auth.ava["uid"][0]
119+
except KeyError:
95120
logger.warning("SAML2 response lacks a 'uid' attestation")
96121
raise SynapseError(400, "uid not in SAML2 response")
97122

123+
try:
124+
mxid_source = saml2_auth.ava[self._mxid_source_attribute][0]
125+
except KeyError:
126+
logger.warning(
127+
"SAML2 response lacks a '%s' attestation", self._mxid_source_attribute
128+
)
129+
raise SynapseError(
130+
400, "%s not in SAML2 response" % (self._mxid_source_attribute,)
131+
)
132+
98133
self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)
99134

100-
username = saml2_auth.ava["uid"][0]
101135
displayName = saml2_auth.ava.get("displayName", [None])[0]
102136

103-
return self._sso_auth_handler.on_successful_auth(
104-
username, request, relay_state, user_display_name=displayName
105-
)
137+
with (await self._mapping_lock.queue(self._auth_provider_id)):
138+
# first of all, check if we already have a mapping for this user
139+
logger.info(
140+
"Looking for existing mapping for user %s:%s",
141+
self._auth_provider_id,
142+
remote_user_id,
143+
)
144+
registered_user_id = await self._datastore.get_user_by_external_id(
145+
self._auth_provider_id, remote_user_id
146+
)
147+
if registered_user_id is not None:
148+
logger.info("Found existing mapping %s", registered_user_id)
149+
return registered_user_id
150+
151+
# backwards-compatibility hack: see if there is an existing user with a
152+
# suitable mapping from the uid
153+
if (
154+
self._grandfathered_mxid_source_attribute
155+
and self._grandfathered_mxid_source_attribute in saml2_auth.ava
156+
):
157+
attrval = saml2_auth.ava[self._grandfathered_mxid_source_attribute][0]
158+
user_id = UserID(
159+
map_username_to_mxid_localpart(attrval), self._hostname
160+
).to_string()
161+
logger.info(
162+
"Looking for existing account based on mapped %s %s",
163+
self._grandfathered_mxid_source_attribute,
164+
user_id,
165+
)
166+
167+
users = await self._datastore.get_users_by_id_case_insensitive(user_id)
168+
if users:
169+
registered_user_id = list(users.keys())[0]
170+
logger.info("Grandfathering mapping to %s", registered_user_id)
171+
await self._datastore.record_user_external_id(
172+
self._auth_provider_id, remote_user_id, registered_user_id
173+
)
174+
return registered_user_id
175+
176+
# figure out a new mxid for this user
177+
base_mxid_localpart = self._mxid_mapper(mxid_source)
178+
179+
suffix = 0
180+
while True:
181+
localpart = base_mxid_localpart + (str(suffix) if suffix else "")
182+
if not await self._datastore.get_users_by_id_case_insensitive(
183+
UserID(localpart, self._hostname).to_string()
184+
):
185+
break
186+
suffix += 1
187+
logger.info("Allocating mxid for new user with localpart %s", localpart)
188+
189+
registered_user_id = await self._registration_handler.register_user(
190+
localpart=localpart, default_display_name=displayName
191+
)
192+
await self._datastore.record_user_external_id(
193+
self._auth_provider_id, remote_user_id, registered_user_id
194+
)
195+
return registered_user_id
106196

107197
def expire_sessions(self):
108198
expire_before = self._clock.time_msec() - self._saml2_session_lifetime

synapse/rest/client/v1/login.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
parse_json_object_from_request,
3030
parse_string,
3131
)
32+
from synapse.http.site import SynapseRequest
3233
from synapse.rest.client.v2_alpha._base import client_patterns
3334
from synapse.rest.well_known import WellKnownBuilder
3435
from synapse.types import UserID, map_username_to_mxid_localpart
@@ -507,6 +508,19 @@ def on_successful_auth(
507508
localpart=localpart, default_display_name=user_display_name
508509
)
509510

511+
self.complete_sso_login(registered_user_id, request, client_redirect_url)
512+
513+
def complete_sso_login(
514+
self, registered_user_id: str, request: SynapseRequest, client_redirect_url: str
515+
):
516+
"""Having figured out a mxid for this user, complete the HTTP request
517+
518+
Args:
519+
registered_user_id:
520+
request:
521+
client_redirect_url:
522+
"""
523+
510524
login_token = self._macaroon_gen.generate_short_term_login_token(
511525
registered_user_id
512526
)

synapse/storage/registration.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from six.moves import range
2323

2424
from twisted.internet import defer
25+
from twisted.internet.defer import Deferred
2526

2627
from synapse.api.constants import UserTypes
2728
from synapse.api.errors import Codes, StoreError, SynapseError, ThreepidValidationError
@@ -406,6 +407,26 @@ def f(txn):
406407

407408
return self.runInteraction("get_users_by_id_case_insensitive", f)
408409

410+
async def get_user_by_external_id(
411+
self, auth_provider: str, external_id: str
412+
) -> str:
413+
"""Look up a user by their external auth id
414+
415+
Args:
416+
auth_provider: identifier for the remote auth provider
417+
external_id: id on that system
418+
419+
Returns:
420+
str|None: the mxid of the user, or None if they are not known
421+
"""
422+
return await self._simple_select_one_onecol(
423+
table="user_external_ids",
424+
keyvalues={"auth_provider": auth_provider, "external_id": external_id},
425+
retcol="user_id",
426+
allow_none=True,
427+
desc="get_user_by_external_id",
428+
)
429+
409430
@defer.inlineCallbacks
410431
def count_all_users(self):
411432
"""Counts all users registered on the homeserver."""
@@ -1054,6 +1075,26 @@ def _register_user(
10541075
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
10551076
txn.call_after(self.is_guest.invalidate, (user_id,))
10561077

1078+
def record_user_external_id(
1079+
self, auth_provider: str, external_id: str, user_id: str
1080+
) -> Deferred:
1081+
"""Record a mapping from an external user id to a mxid
1082+
1083+
Args:
1084+
auth_provider: identifier for the remote auth provider
1085+
external_id: id on that system
1086+
user_id: complete mxid that it is mapped to
1087+
"""
1088+
return self._simple_insert(
1089+
table="user_external_ids",
1090+
values={
1091+
"auth_provider": auth_provider,
1092+
"external_id": external_id,
1093+
"user_id": user_id,
1094+
},
1095+
desc="record_user_external_id",
1096+
)
1097+
10571098
def user_set_password_hash(self, user_id, password_hash):
10581099
"""
10591100
NB. This does *not* evict any cache because the one use for this

0 commit comments

Comments
 (0)