From b4329db27aa91ee5e2ebc78fb824e7300a5f92d7 Mon Sep 17 00:00:00 2001 From: Patrick Cloke Date: Wed, 25 Mar 2020 09:03:51 -0400 Subject: [PATCH] Support CAS in UI Auth flows. --- synapse/handlers/auth.py | 4 +- synapse/handlers/cas_handler.py | 158 ++++++++++++++++----------- synapse/rest/client/v1/login.py | 6 +- synapse/rest/client/v2_alpha/auth.py | 47 +++++++- 4 files changed, 143 insertions(+), 72 deletions(-) diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index 7c09d15a72..892adb00b9 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -116,7 +116,7 @@ class AuthHandler(BaseHandler): self.hs = hs # FIXME better possibility to access registrationHandler later? self.macaroon_gen = hs.get_macaroon_generator() self._password_enabled = hs.config.password_enabled - self._saml2_enabled = hs.config.saml2_enabled + self._sso_enabled = hs.config.saml2_enabled or hs.config.cas_enabled # we keep this as a list despite the O(N^2) implication so that we can # keep PASSWORD first and avoid confusing clients which pick the first @@ -136,7 +136,7 @@ class AuthHandler(BaseHandler): # necessarily identical. Login types have SSO (and other login types) # added in the rest layer, see synapse.rest.client.v1.login.LoginRestServerlet.on_GET. ui_auth_types = login_types.copy() - if self._saml2_enabled: + if self._sso_enabled: ui_auth_types.append(LoginType.SSO) self._supported_ui_auth_types = ui_auth_types diff --git a/synapse/handlers/cas_handler.py b/synapse/handlers/cas_handler.py index f8dc274b78..af86baaa07 100644 --- a/synapse/handlers/cas_handler.py +++ b/synapse/handlers/cas_handler.py @@ -15,7 +15,7 @@ import logging import xml.etree.ElementTree as ET -from typing import AnyStr, Dict, Optional, Tuple +from typing import Dict, Optional, Tuple from six.moves import urllib @@ -48,26 +48,53 @@ class CasHandler: self._http_client = hs.get_proxied_http_client() - def _build_service_param(self, client_redirect_url: AnyStr) -> str: - return "%s%s?%s" % ( - self._cas_service_url, - "/_matrix/client/r0/login/cas/ticket", - urllib.parse.urlencode({"redirectUrl": client_redirect_url}), - ) - - async def _handle_cas_response( - self, request: SynapseRequest, cas_response_body: str, client_redirect_url: str - ) -> None: + def _build_service_param(self, service_redirect_endpoint: str, **kwargs) -> str: """ - Retrieves the user and display name from the CAS response and continues with the authentication. + Generates a value to use as the "service" parameter when redirecting or + querying the CAS service. Args: - request: The original client request. - cas_response_body: The response from the CAS server. - client_redirect_url: The URl to redirect the client to when - everything is done. + service_redirect_endpoint: The homeserver endpoint to redirect + the client to after successful SSO negotiation. + kwargs: Additional arguments to include in the final redirect URL. + + Returns: + The URL to use as a "service" parameter. """ - user, attributes = self._parse_cas_response(cas_response_body) + return "%s%s?%s" % ( + self._cas_service_url, + service_redirect_endpoint, + urllib.parse.urlencode(kwargs), + ) + + async def _validate_ticket( + self, ticket: str, service_redirect_endpoint: str, client_redirect_url: str + ) -> Tuple[str, Optional[str]]: + """ + Validate a CAS ticket with the server, parse the response, and return the user and display name. + + Args: + ticket: The CAS ticket from the client. + service_redirect_endpoint: The homeserver endpoint that the client + accessed to validate the ticket. + client_redirect_url: The URL to redirect the client to after + validation is done. + """ + uri = self._cas_server_url + "/proxyValidate" + args = { + "ticket": ticket, + "service": self._build_service_param( + service_redirect_endpoint, redirectUrl=client_redirect_url + ), + } + try: + body = await self._http_client.get_raw(uri, args) + except PartialDownloadError as pde: + # Twisted raises this error if the connection is closed, + # even if that's being used old-http style to signal end-of-data + body = pde.response + + user, attributes = self._parse_cas_response(body) displayname = attributes.pop(self._cas_displayname_attribute, None) for required_attribute, required_value in self._cas_required_attributes.items(): @@ -82,7 +109,7 @@ class CasHandler: if required_value != actual_value: raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED) - await self._on_successful_auth(user, request, client_redirect_url, displayname) + return user, displayname def _parse_cas_response( self, cas_response_body: str @@ -127,31 +154,46 @@ class CasHandler: ) return user, attributes - async def _on_successful_auth( - self, - username: str, - request: SynapseRequest, - client_redirect_url: str, - user_display_name: Optional[str] = None, + def get_redirect_url(self, service_redirect_endpoint: str, **kwargs) -> str: + """ + Generates a URL to the CAS server where the client should be redirected. + + Args: + service_redirect_endpoint: The homeserver endpoint to redirect + the client to after successful SSO negotiation. + kwargs: Additional arguments to include in the final redirect URL. + + Returns: + The URL to redirect the client to. + """ + args = urllib.parse.urlencode( + {"service": self._build_service_param(service_redirect_endpoint, **kwargs)} + ) + + return "%s/login?%s" % (self._cas_server_url, args) + + async def handle_login_request( + self, request: SynapseRequest, client_redirect_url: str, ticket: str ) -> None: - """Called once the user has successfully authenticated with the SSO. + """ + Validates a CAS ticket sent by the client for login and authenticates the user with SSO. Registers the user if necessary, and then returns a redirect (with a login token) to the client. Args: - username: the remote user id. We'll map this onto - something sane for a MXID localpath. - request: the incoming request from the browser. We'll respond to it with a redirect. client_redirect_url: the redirect_url the client gave us when it first started the process. - user_display_name: if set, and we have to register a new user, - we will set their displayname to this. + ticket: The CAS ticket provided by the client. """ + username, user_display_name = await self._validate_ticket( + ticket, "/_matrix/client/r0/login/cas/ticket", client_redirect_url + ) + localpart = map_username_to_mxid_localpart(username) user_id = UserID(localpart, self._hostname).to_string() registered_user_id = await self._auth_handler.check_user_exists(user_id) @@ -164,41 +206,31 @@ class CasHandler: registered_user_id, request, client_redirect_url ) - def handle_redirect_request(self, client_redirect_url: bytes) -> bytes: - """ - Generates a URL to the CAS server where the client should be redirected. - - Args: - client_redirect_url: The final URL the client should go to after the - user has negotiated SSO. - - Returns: - The URL to redirect to. - """ - args = urllib.parse.urlencode( - {"service": self._build_service_param(client_redirect_url)} - ) - - return ("%s/login?%s" % (self._cas_server_url, args)).encode("ascii") - - async def handle_ticket_request( - self, request: SynapseRequest, client_redirect_url: str, ticket: str + async def handle_ui_auth_response( + self, request: SynapseRequest, ticket: str, session_id: str ) -> None: """ - Validates a CAS ticket sent by the client for login/registration. + Validates a CAS ticket sent by the client for user interactive authentication. - On a successful request, writes a redirect to the request. + If successful, this completes the SSO step of UI auth and returns a + an HTML page to the client. + + Args: + request: the incoming request from the browser. + + ticket: The CAS ticket provided by the client. + + session_id: The UI Auth session ID. """ - uri = self._cas_server_url + "/proxyValidate" - args = { - "ticket": ticket, - "service": self._build_service_param(client_redirect_url), - } - try: - body = await self._http_client.get_raw(uri, args) - except PartialDownloadError as pde: - # Twisted raises this error if the connection is closed, - # even if that's being used old-http style to signal end-of-data - body = pde.response + client_redirect_url = "" + user, _ = await self._validate_ticket( + ticket, "/_matrix/client/r0/auth/cas/ticket", client_redirect_url + ) - await self._handle_cas_response(request, body, client_redirect_url) + localpart = map_username_to_mxid_localpart(user) + user_id = UserID(localpart, self._hostname).to_string() + registered_user_id = await self._auth_handler.check_user_exists(user_id) + + self._auth_handler.complete_sso_ui_auth( + registered_user_id, session_id, request, + ) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index 59593cbf6e..8beb2f1ed2 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -425,7 +425,9 @@ class CasRedirectServlet(BaseSSORedirectServlet): self._cas_handler = hs.get_cas_handler() def get_sso_url(self, client_redirect_url: bytes) -> bytes: - return self._cas_handler.handle_redirect_request(client_redirect_url) + return self._cas_handler.get_redirect_url( + "/_matrix/client/r0/login/cas/ticket", redirectUrl=client_redirect_url + ).encode("ascii") class CasTicketServlet(RestServlet): @@ -438,7 +440,7 @@ class CasTicketServlet(RestServlet): async def on_GET(self, request: SynapseRequest) -> None: client_redirect_url = parse_string(request, "redirectUrl", required=True) ticket = parse_string(request, "ticket", required=True) - await self._cas_handler.handle_ticket_request( + await self._cas_handler.handle_login_request( request, client_redirect_url, ticket ) diff --git a/synapse/rest/client/v2_alpha/auth.py b/synapse/rest/client/v2_alpha/auth.py index 1787562b90..1a41bb73e6 100644 --- a/synapse/rest/client/v2_alpha/auth.py +++ b/synapse/rest/client/v2_alpha/auth.py @@ -111,6 +111,11 @@ class AuthRestServlet(RestServlet): self._saml_enabled = hs.config.saml2_enabled if self._saml_enabled: self._saml_handler = hs.get_saml_handler() + self._cas_enabled = hs.config.cas_enabled + if self._cas_enabled: + self._cas_handler = hs.get_cas_handler() + self._cas_server_url = hs.config.cas_server_url + self._cas_service_url = hs.config.cas_service_url def on_GET(self, request, stagetype): session = parse_string(request, "session") @@ -133,14 +138,27 @@ class AuthRestServlet(RestServlet): % (CLIENT_API_PREFIX, LoginType.TERMS), } - elif stagetype == LoginType.SSO and self._saml_enabled: + elif stagetype == LoginType.SSO: # Display a confirmation page which prompts the user to # re-authenticate with their SSO provider. - client_redirect_url = "" - sso_redirect_url = self._saml_handler.handle_redirect_request( - client_redirect_url, session - ) + if self._cas_enabled: + # Generate a request to CAS that redirects back to an endpoint + # to verify the successful authentication. + sso_redirect_url = self._cas_handler.get_redirect_url( + "/_matrix/client/r0/auth/cas/ticket", session=session, + ) + + elif self._saml_enabled: + client_redirect_url = "" + sso_redirect_url = self._saml_handler.handle_redirect_request( + client_redirect_url, session + ) + + else: + raise SynapseError(400, "Homeserver not configured for SSO.") + html = self.auth_handler.start_sso_ui_auth(sso_redirect_url, session) + else: raise SynapseError(404, "Unknown auth stage type") @@ -221,5 +239,24 @@ class AuthRestServlet(RestServlet): return 200, {} +class CasAuthTicketServlet(RestServlet): + PATTERNS = client_patterns(r"/auth/cas/ticket") + + def __init__(self, hs): + super(CasAuthTicketServlet, self).__init__() + self._cas_handler = hs.get_cas_handler() + + async def on_GET(self, request): + ticket = parse_string(request, "ticket", required=True) + # Pull the UI Auth session ID out. + session_id = parse_string(request, "session", required=True) + + return await self._cas_handler.handle_ui_auth_response( + request, ticket, session_id + ) + + def register_servlets(hs, http_server): AuthRestServlet(hs).register(http_server) + if hs.config.cas_enabled: + CasAuthTicketServlet(hs).register(http_server)