cas_handler.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2020 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 urllib
  17. import xml.etree.ElementTree as ET
  18. from typing import Dict, Optional, Tuple
  19. from twisted.web.client import PartialDownloadError
  20. from synapse.api.errors import Codes, LoginError
  21. from synapse.http.site import SynapseRequest
  22. from synapse.types import UserID, map_username_to_mxid_localpart
  23. logger = logging.getLogger(__name__)
  24. class CasHandler:
  25. """
  26. Utility class for to handle the response from a CAS SSO service.
  27. Args:
  28. hs (synapse.server.HomeServer)
  29. """
  30. def __init__(self, hs):
  31. self._hostname = hs.hostname
  32. self._auth_handler = hs.get_auth_handler()
  33. self._registration_handler = hs.get_registration_handler()
  34. self._cas_server_url = hs.config.cas_server_url
  35. self._cas_service_url = hs.config.cas_service_url
  36. self._cas_displayname_attribute = hs.config.cas_displayname_attribute
  37. self._cas_required_attributes = hs.config.cas_required_attributes
  38. self._http_client = hs.get_proxied_http_client()
  39. def _build_service_param(self, args: Dict[str, str]) -> str:
  40. """
  41. Generates a value to use as the "service" parameter when redirecting or
  42. querying the CAS service.
  43. Args:
  44. args: Additional arguments to include in the final redirect URL.
  45. Returns:
  46. The URL to use as a "service" parameter.
  47. """
  48. return "%s%s?%s" % (
  49. self._cas_service_url,
  50. "/_matrix/client/r0/login/cas/ticket",
  51. urllib.parse.urlencode(args),
  52. )
  53. async def _validate_ticket(
  54. self, ticket: str, service_args: Dict[str, str]
  55. ) -> Tuple[str, Optional[str]]:
  56. """
  57. Validate a CAS ticket with the server, parse the response, and return the user and display name.
  58. Args:
  59. ticket: The CAS ticket from the client.
  60. service_args: Additional arguments to include in the service URL.
  61. Should be the same as those passed to `get_redirect_url`.
  62. """
  63. uri = self._cas_server_url + "/proxyValidate"
  64. args = {
  65. "ticket": ticket,
  66. "service": self._build_service_param(service_args),
  67. }
  68. try:
  69. body = await self._http_client.get_raw(uri, args)
  70. except PartialDownloadError as pde:
  71. # Twisted raises this error if the connection is closed,
  72. # even if that's being used old-http style to signal end-of-data
  73. body = pde.response
  74. user, attributes = self._parse_cas_response(body)
  75. displayname = attributes.pop(self._cas_displayname_attribute, None)
  76. for required_attribute, required_value in self._cas_required_attributes.items():
  77. # If required attribute was not in CAS Response - Forbidden
  78. if required_attribute not in attributes:
  79. raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)
  80. # Also need to check value
  81. if required_value is not None:
  82. actual_value = attributes[required_attribute]
  83. # If required attribute value does not match expected - Forbidden
  84. if required_value != actual_value:
  85. raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)
  86. return user, displayname
  87. def _parse_cas_response(
  88. self, cas_response_body: str
  89. ) -> Tuple[str, Dict[str, Optional[str]]]:
  90. """
  91. Retrieve the user and other parameters from the CAS response.
  92. Args:
  93. cas_response_body: The response from the CAS query.
  94. Returns:
  95. A tuple of the user and a mapping of other attributes.
  96. """
  97. user = None
  98. attributes = {}
  99. try:
  100. root = ET.fromstring(cas_response_body)
  101. if not root.tag.endswith("serviceResponse"):
  102. raise Exception("root of CAS response is not serviceResponse")
  103. success = root[0].tag.endswith("authenticationSuccess")
  104. for child in root[0]:
  105. if child.tag.endswith("user"):
  106. user = child.text
  107. if child.tag.endswith("attributes"):
  108. for attribute in child:
  109. # ElementTree library expands the namespace in
  110. # attribute tags to the full URL of the namespace.
  111. # We don't care about namespace here and it will always
  112. # be encased in curly braces, so we remove them.
  113. tag = attribute.tag
  114. if "}" in tag:
  115. tag = tag.split("}")[1]
  116. attributes[tag] = attribute.text
  117. if user is None:
  118. raise Exception("CAS response does not contain user")
  119. except Exception:
  120. logger.exception("Error parsing CAS response")
  121. raise LoginError(401, "Invalid CAS response", errcode=Codes.UNAUTHORIZED)
  122. if not success:
  123. raise LoginError(
  124. 401, "Unsuccessful CAS response", errcode=Codes.UNAUTHORIZED
  125. )
  126. return user, attributes
  127. def get_redirect_url(self, service_args: Dict[str, str]) -> str:
  128. """
  129. Generates a URL for the CAS server where the client should be redirected.
  130. Args:
  131. service_args: Additional arguments to include in the final redirect URL.
  132. Returns:
  133. The URL to redirect the client to.
  134. """
  135. args = urllib.parse.urlencode(
  136. {"service": self._build_service_param(service_args)}
  137. )
  138. return "%s/login?%s" % (self._cas_server_url, args)
  139. async def handle_ticket(
  140. self,
  141. request: SynapseRequest,
  142. ticket: str,
  143. client_redirect_url: Optional[str],
  144. session: Optional[str],
  145. ) -> None:
  146. """
  147. Called once the user has successfully authenticated with the SSO.
  148. Validates a CAS ticket sent by the client and completes the auth process.
  149. If the user interactive authentication session is provided, marks the
  150. UI Auth session as complete, then returns an HTML page notifying the
  151. user they are done.
  152. Otherwise, this registers the user if necessary, and then returns a
  153. redirect (with a login token) to the client.
  154. Args:
  155. request: the incoming request from the browser. We'll
  156. respond to it with a redirect or an HTML page.
  157. ticket: The CAS ticket provided by the client.
  158. client_redirect_url: the redirectUrl parameter from the `/cas/ticket` HTTP request, if given.
  159. This should be the same as the redirectUrl from the original `/login/sso/redirect` request.
  160. session: The session parameter from the `/cas/ticket` HTTP request, if given.
  161. This should be the UI Auth session id.
  162. """
  163. args = {}
  164. if client_redirect_url:
  165. args["redirectUrl"] = client_redirect_url
  166. if session:
  167. args["session"] = session
  168. username, user_display_name = await self._validate_ticket(ticket, args)
  169. localpart = map_username_to_mxid_localpart(username)
  170. user_id = UserID(localpart, self._hostname).to_string()
  171. registered_user_id = await self._auth_handler.check_user_exists(user_id)
  172. if session:
  173. await self._auth_handler.complete_sso_ui_auth(
  174. registered_user_id, session, request,
  175. )
  176. else:
  177. if not registered_user_id:
  178. registered_user_id = await self._registration_handler.register_user(
  179. localpart=localpart, default_display_name=user_display_name
  180. )
  181. await self._auth_handler.complete_sso_login(
  182. registered_user_id, request, client_redirect_url
  183. )