saml_handler.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2019 The Matrix.org Foundation C.I.C.
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import logging
  16. import re
  17. from typing import Tuple
  18. import attr
  19. import saml2
  20. import saml2.response
  21. from saml2.client import Saml2Client
  22. from synapse.api.errors import SynapseError
  23. from synapse.config import ConfigError
  24. from synapse.http.server import finish_request
  25. from synapse.http.servlet import parse_string
  26. from synapse.module_api import ModuleApi
  27. from synapse.types import (
  28. UserID,
  29. map_username_to_mxid_localpart,
  30. mxid_localpart_allowed_characters,
  31. )
  32. from synapse.util.async_helpers import Linearizer
  33. from synapse.util.iterutils import chunk_seq
  34. logger = logging.getLogger(__name__)
  35. @attr.s
  36. class Saml2SessionData:
  37. """Data we track about SAML2 sessions"""
  38. # time the session was created, in milliseconds
  39. creation_time = attr.ib()
  40. class SamlHandler:
  41. def __init__(self, hs):
  42. self._saml_client = Saml2Client(hs.config.saml2_sp_config)
  43. self._auth_handler = hs.get_auth_handler()
  44. self._registration_handler = hs.get_registration_handler()
  45. self._clock = hs.get_clock()
  46. self._datastore = hs.get_datastore()
  47. self._hostname = hs.hostname
  48. self._saml2_session_lifetime = hs.config.saml2_session_lifetime
  49. self._grandfathered_mxid_source_attribute = (
  50. hs.config.saml2_grandfathered_mxid_source_attribute
  51. )
  52. # plugin to do custom mapping from saml response to mxid
  53. self._user_mapping_provider = hs.config.saml2_user_mapping_provider_class(
  54. hs.config.saml2_user_mapping_provider_config,
  55. ModuleApi(hs, hs.get_auth_handler()),
  56. )
  57. # identifier for the external_ids table
  58. self._auth_provider_id = "saml"
  59. # a map from saml session id to Saml2SessionData object
  60. self._outstanding_requests_dict = {}
  61. # a lock on the mappings
  62. self._mapping_lock = Linearizer(name="saml_mapping", clock=self._clock)
  63. self._error_html_content = hs.config.saml2_error_html_content
  64. def handle_redirect_request(self, client_redirect_url):
  65. """Handle an incoming request to /login/sso/redirect
  66. Args:
  67. client_redirect_url (bytes): the URL that we should redirect the
  68. client to when everything is done
  69. Returns:
  70. bytes: URL to redirect to
  71. """
  72. reqid, info = self._saml_client.prepare_for_authenticate(
  73. relay_state=client_redirect_url
  74. )
  75. now = self._clock.time_msec()
  76. self._outstanding_requests_dict[reqid] = Saml2SessionData(creation_time=now)
  77. for key, value in info["headers"]:
  78. if key == "Location":
  79. return value
  80. # this shouldn't happen!
  81. raise Exception("prepare_for_authenticate didn't return a Location header")
  82. async def handle_saml_response(self, request):
  83. """Handle an incoming request to /_matrix/saml2/authn_response
  84. Args:
  85. request (SynapseRequest): the incoming request from the browser. We'll
  86. respond to it with a redirect.
  87. Returns:
  88. Deferred[none]: Completes once we have handled the request.
  89. """
  90. resp_bytes = parse_string(request, "SAMLResponse", required=True)
  91. relay_state = parse_string(request, "RelayState", required=True)
  92. # expire outstanding sessions before parse_authn_request_response checks
  93. # the dict.
  94. self.expire_sessions()
  95. try:
  96. user_id = await self._map_saml_response_to_user(resp_bytes, relay_state)
  97. except Exception as e:
  98. # If decoding the response or mapping it to a user failed, then log the
  99. # error and tell the user that something went wrong.
  100. logger.error(e)
  101. request.setResponseCode(400)
  102. request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
  103. request.setHeader(
  104. b"Content-Length", b"%d" % (len(self._error_html_content),)
  105. )
  106. request.write(self._error_html_content.encode("utf8"))
  107. finish_request(request)
  108. return
  109. self._auth_handler.complete_sso_login(user_id, request, relay_state)
  110. async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
  111. try:
  112. saml2_auth = self._saml_client.parse_authn_request_response(
  113. resp_bytes,
  114. saml2.BINDING_HTTP_POST,
  115. outstanding=self._outstanding_requests_dict,
  116. )
  117. except Exception as e:
  118. logger.warning("Exception parsing SAML2 response: %s", e)
  119. raise SynapseError(400, "Unable to parse SAML2 response: %s" % (e,))
  120. if saml2_auth.not_signed:
  121. logger.warning("SAML2 response was not signed")
  122. raise SynapseError(400, "SAML2 response was not signed")
  123. logger.debug("SAML2 response: %s", saml2_auth.origxml)
  124. for assertion in saml2_auth.assertions:
  125. # kibana limits the length of a log field, whereas this is all rather
  126. # useful, so split it up.
  127. count = 0
  128. for part in chunk_seq(str(assertion), 10000):
  129. logger.info(
  130. "SAML2 assertion: %s%s", "(%i)..." % (count,) if count else "", part
  131. )
  132. count += 1
  133. logger.info("SAML2 mapped attributes: %s", saml2_auth.ava)
  134. self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)
  135. remote_user_id = self._user_mapping_provider.get_remote_user_id(
  136. saml2_auth, client_redirect_url
  137. )
  138. if not remote_user_id:
  139. raise Exception("Failed to extract remote user id from SAML response")
  140. with (await self._mapping_lock.queue(self._auth_provider_id)):
  141. # first of all, check if we already have a mapping for this user
  142. logger.info(
  143. "Looking for existing mapping for user %s:%s",
  144. self._auth_provider_id,
  145. remote_user_id,
  146. )
  147. registered_user_id = await self._datastore.get_user_by_external_id(
  148. self._auth_provider_id, remote_user_id
  149. )
  150. if registered_user_id is not None:
  151. logger.info("Found existing mapping %s", registered_user_id)
  152. return registered_user_id
  153. # backwards-compatibility hack: see if there is an existing user with a
  154. # suitable mapping from the uid
  155. if (
  156. self._grandfathered_mxid_source_attribute
  157. and self._grandfathered_mxid_source_attribute in saml2_auth.ava
  158. ):
  159. attrval = saml2_auth.ava[self._grandfathered_mxid_source_attribute][0]
  160. user_id = UserID(
  161. map_username_to_mxid_localpart(attrval), self._hostname
  162. ).to_string()
  163. logger.info(
  164. "Looking for existing account based on mapped %s %s",
  165. self._grandfathered_mxid_source_attribute,
  166. user_id,
  167. )
  168. users = await self._datastore.get_users_by_id_case_insensitive(user_id)
  169. if users:
  170. registered_user_id = list(users.keys())[0]
  171. logger.info("Grandfathering mapping to %s", registered_user_id)
  172. await self._datastore.record_user_external_id(
  173. self._auth_provider_id, remote_user_id, registered_user_id
  174. )
  175. return registered_user_id
  176. # Map saml response to user attributes using the configured mapping provider
  177. for i in range(1000):
  178. attribute_dict = self._user_mapping_provider.saml_response_to_user_attributes(
  179. saml2_auth, i, client_redirect_url=client_redirect_url,
  180. )
  181. logger.debug(
  182. "Retrieved SAML attributes from user mapping provider: %s "
  183. "(attempt %d)",
  184. attribute_dict,
  185. i,
  186. )
  187. localpart = attribute_dict.get("mxid_localpart")
  188. if not localpart:
  189. logger.error(
  190. "SAML mapping provider plugin did not return a "
  191. "mxid_localpart object"
  192. )
  193. raise SynapseError(500, "Error parsing SAML2 response")
  194. displayname = attribute_dict.get("displayname")
  195. # Check if this mxid already exists
  196. if not await self._datastore.get_users_by_id_case_insensitive(
  197. UserID(localpart, self._hostname).to_string()
  198. ):
  199. # This mxid is free
  200. break
  201. else:
  202. # Unable to generate a username in 1000 iterations
  203. # Break and return error to the user
  204. raise SynapseError(
  205. 500, "Unable to generate a Matrix ID from the SAML response"
  206. )
  207. logger.info("Mapped SAML user to local part %s", localpart)
  208. registered_user_id = await self._registration_handler.register_user(
  209. localpart=localpart, default_display_name=displayname
  210. )
  211. await self._datastore.record_user_external_id(
  212. self._auth_provider_id, remote_user_id, registered_user_id
  213. )
  214. return registered_user_id
  215. def expire_sessions(self):
  216. expire_before = self._clock.time_msec() - self._saml2_session_lifetime
  217. to_expire = set()
  218. for reqid, data in self._outstanding_requests_dict.items():
  219. if data.creation_time < expire_before:
  220. to_expire.add(reqid)
  221. for reqid in to_expire:
  222. logger.debug("Expiring session id %s", reqid)
  223. del self._outstanding_requests_dict[reqid]
  224. DOT_REPLACE_PATTERN = re.compile(
  225. ("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),))
  226. )
  227. def dot_replace_for_mxid(username: str) -> str:
  228. username = username.lower()
  229. username = DOT_REPLACE_PATTERN.sub(".", username)
  230. # regular mxids aren't allowed to start with an underscore either
  231. username = re.sub("^_", "", username)
  232. return username
  233. MXID_MAPPER_MAP = {
  234. "hexencode": map_username_to_mxid_localpart,
  235. "dotreplace": dot_replace_for_mxid,
  236. }
  237. @attr.s
  238. class SamlConfig(object):
  239. mxid_source_attribute = attr.ib()
  240. mxid_mapper = attr.ib()
  241. class DefaultSamlMappingProvider(object):
  242. __version__ = "0.0.1"
  243. def __init__(self, parsed_config: SamlConfig, module_api: ModuleApi):
  244. """The default SAML user mapping provider
  245. Args:
  246. parsed_config: Module configuration
  247. module_api: module api proxy
  248. """
  249. self._mxid_source_attribute = parsed_config.mxid_source_attribute
  250. self._mxid_mapper = parsed_config.mxid_mapper
  251. self._grandfathered_mxid_source_attribute = (
  252. module_api._hs.config.saml2_grandfathered_mxid_source_attribute
  253. )
  254. def get_remote_user_id(
  255. self, saml_response: saml2.response.AuthnResponse, client_redirect_url: str
  256. ):
  257. """Extracts the remote user id from the SAML response"""
  258. try:
  259. return saml_response.ava["uid"][0]
  260. except KeyError:
  261. logger.warning("SAML2 response lacks a 'uid' attestation")
  262. raise SynapseError(400, "'uid' not in SAML2 response")
  263. def saml_response_to_user_attributes(
  264. self,
  265. saml_response: saml2.response.AuthnResponse,
  266. failures: int,
  267. client_redirect_url: str,
  268. ) -> dict:
  269. """Maps some text from a SAML response to attributes of a new user
  270. Args:
  271. saml_response: A SAML auth response object
  272. failures: How many times a call to this function with this
  273. saml_response has resulted in a failure
  274. client_redirect_url: where the client wants to redirect to
  275. Returns:
  276. dict: A dict containing new user attributes. Possible keys:
  277. * mxid_localpart (str): Required. The localpart of the user's mxid
  278. * displayname (str): The displayname of the user
  279. """
  280. try:
  281. mxid_source = saml_response.ava[self._mxid_source_attribute][0]
  282. except KeyError:
  283. logger.warning(
  284. "SAML2 response lacks a '%s' attestation", self._mxid_source_attribute,
  285. )
  286. raise SynapseError(
  287. 400, "%s not in SAML2 response" % (self._mxid_source_attribute,)
  288. )
  289. # Use the configured mapper for this mxid_source
  290. base_mxid_localpart = self._mxid_mapper(mxid_source)
  291. # Append suffix integer if last call to this function failed to produce
  292. # a usable mxid
  293. localpart = base_mxid_localpart + (str(failures) if failures else "")
  294. # Retrieve the display name from the saml response
  295. # If displayname is None, the mxid_localpart will be used instead
  296. displayname = saml_response.ava.get("displayName", [None])[0]
  297. return {
  298. "mxid_localpart": localpart,
  299. "displayname": displayname,
  300. }
  301. @staticmethod
  302. def parse_config(config: dict) -> SamlConfig:
  303. """Parse the dict provided by the homeserver's config
  304. Args:
  305. config: A dictionary containing configuration options for this provider
  306. Returns:
  307. SamlConfig: A custom config object for this module
  308. """
  309. # Parse config options and use defaults where necessary
  310. mxid_source_attribute = config.get("mxid_source_attribute", "uid")
  311. mapping_type = config.get("mxid_mapping", "hexencode")
  312. # Retrieve the associating mapping function
  313. try:
  314. mxid_mapper = MXID_MAPPER_MAP[mapping_type]
  315. except KeyError:
  316. raise ConfigError(
  317. "saml2_config.user_mapping_provider.config: '%s' is not a valid "
  318. "mxid_mapping value" % (mapping_type,)
  319. )
  320. return SamlConfig(mxid_source_attribute, mxid_mapper)
  321. @staticmethod
  322. def get_saml_attributes(config: SamlConfig) -> Tuple[set, set]:
  323. """Returns the required attributes of a SAML
  324. Args:
  325. config: A SamlConfig object containing configuration params for this provider
  326. Returns:
  327. tuple[set,set]: The first set equates to the saml auth response
  328. attributes that are required for the module to function, whereas the
  329. second set consists of those attributes which can be used if
  330. available, but are not necessary
  331. """
  332. return {"uid", config.mxid_source_attribute}, {"displayName"}