summary refs log tree commit diff
diff options
context:
space:
mode:
authorPatrick Cloke <clokep@users.noreply.github.com>2020-04-03 15:35:05 -0400
committerGitHub <noreply@github.com>2020-04-03 15:35:05 -0400
commit694d8bed0e56366f080a49db0f930d635ca6cdf4 (patch)
treeb4a5fddf3cb5d5ae124bba9788483b6477fc1747
parentExtend web_client_location to handle absolute URLs (#7006) (diff)
downloadsynapse-694d8bed0e56366f080a49db0f930d635ca6cdf4.tar.xz
Support CAS in UI Auth flows. (#7186)
-rw-r--r--changelog.d/7186.feature1
-rw-r--r--synapse/handlers/auth.py4
-rw-r--r--synapse/handlers/cas_handler.py161
-rw-r--r--synapse/rest/client/v1/login.py20
-rw-r--r--synapse/rest/client/v2_alpha/auth.py28
5 files changed, 131 insertions, 83 deletions
diff --git a/changelog.d/7186.feature b/changelog.d/7186.feature
new file mode 100644
index 0000000000..01057aa396
--- /dev/null
+++ b/changelog.d/7186.feature
@@ -0,0 +1 @@
+Support SSO in the user interactive authentication workflow.
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..d977badf35 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,47 @@ class CasHandler:
 
         self._http_client = hs.get_proxied_http_client()
 
-    def _build_service_param(self, client_redirect_url: AnyStr) -> str:
+    def _build_service_param(self, args: Dict[str, str]) -> str:
+        """
+        Generates a value to use as the "service" parameter when redirecting or
+        querying the CAS service.
+
+        Args:
+            args: Additional arguments to include in the final redirect URL.
+
+        Returns:
+            The URL to use as a "service" parameter.
+        """
         return "%s%s?%s" % (
             self._cas_service_url,
             "/_matrix/client/r0/login/cas/ticket",
-            urllib.parse.urlencode({"redirectUrl": client_redirect_url}),
+            urllib.parse.urlencode(args),
         )
 
-    async def _handle_cas_response(
-        self, request: SynapseRequest, cas_response_body: str, client_redirect_url: str
-    ) -> None:
+    async def _validate_ticket(
+        self, ticket: str, service_args: Dict[str, str]
+    ) -> Tuple[str, Optional[str]]:
         """
-        Retrieves the user and display name from the CAS response and continues with the authentication.
+        Validate a CAS ticket with the server, parse the response, and return the user and display name.
 
         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.
+            ticket: The CAS ticket from the client.
+            service_args: Additional arguments to include in the service URL.
+                Should be the same as those passed to `get_redirect_url`.
         """
-        user, attributes = self._parse_cas_response(cas_response_body)
+        uri = self._cas_server_url + "/proxyValidate"
+        args = {
+            "ticket": ticket,
+            "service": self._build_service_param(service_args),
+        }
+        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 +103,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,78 +148,74 @@ 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,
-    ) -> None:
-        """Called once the user has successfully authenticated with the SSO.
-
-        Registers the user if necessary, and then returns a redirect (with
-        a login token) to the client.
+    def get_redirect_url(self, service_args: Dict[str, str]) -> str:
+        """
+        Generates a URL for the CAS server where the client should be redirected.
 
         Args:
-            username: the remote user id. We'll map this onto
-                something sane for a MXID localpath.
+            service_args: Additional arguments to include in the final redirect URL.
 
-            request: the incoming request from the browser. We'll
-                respond to it with a redirect.
+        Returns:
+            The URL to redirect the client to.
+        """
+        args = urllib.parse.urlencode(
+            {"service": self._build_service_param(service_args)}
+        )
 
-            client_redirect_url: the redirect_url the client gave us when
-                it first started the process.
+        return "%s/login?%s" % (self._cas_server_url, args)
 
-            user_display_name: if set, and we have to register a new user,
-                we will set their displayname to this.
+    async def handle_ticket(
+        self,
+        request: SynapseRequest,
+        ticket: str,
+        client_redirect_url: Optional[str],
+        session: Optional[str],
+    ) -> None:
         """
-        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)
-        if not registered_user_id:
-            registered_user_id = await self._registration_handler.register_user(
-                localpart=localpart, default_display_name=user_display_name
-            )
+        Called once the user has successfully authenticated with the SSO.
+        Validates a CAS ticket sent by the client and completes the auth process.
 
-        self._auth_handler.complete_sso_login(
-            registered_user_id, request, client_redirect_url
-        )
+        If the user interactive authentication session is provided, marks the
+        UI Auth session as complete, then returns an HTML page notifying the
+        user they are done.
 
-    def handle_redirect_request(self, client_redirect_url: bytes) -> bytes:
-        """
-        Generates a URL to the CAS server where the client should be redirected.
+        Otherwise, this registers the user if necessary, and then returns a
+        redirect (with a login token) to the client.
 
         Args:
-            client_redirect_url: The final URL the client should go to after the
-                user has negotiated SSO.
+            request: the incoming request from the browser. We'll
+                respond to it with a redirect or an HTML page.
 
-        Returns:
-            The URL to redirect to.
-        """
-        args = urllib.parse.urlencode(
-            {"service": self._build_service_param(client_redirect_url)}
-        )
+            ticket: The CAS ticket provided by the client.
 
-        return ("%s/login?%s" % (self._cas_server_url, args)).encode("ascii")
+            client_redirect_url: the redirectUrl parameter from the `/cas/ticket` HTTP request, if given.
+                This should be the same as the redirectUrl from the original `/login/sso/redirect` request.
 
-    async def handle_ticket_request(
-        self, request: SynapseRequest, client_redirect_url: str, ticket: str
-    ) -> None:
+            session: The session parameter from the `/cas/ticket` HTTP request, if given.
+                This should be the UI Auth session id.
         """
-        Validates a CAS ticket sent by the client for login/registration.
+        args = {}
+        if client_redirect_url:
+            args["redirectUrl"] = client_redirect_url
+        if session:
+            args["session"] = session
+        username, user_display_name = await self._validate_ticket(ticket, args)
 
-        On a successful request, writes a redirect to the request.
-        """
-        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
+        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)
 
-        await self._handle_cas_response(request, body, client_redirect_url)
+        if session:
+            self._auth_handler.complete_sso_ui_auth(
+                registered_user_id, session, request,
+            )
+
+        else:
+            if not registered_user_id:
+                registered_user_id = await self._registration_handler.register_user(
+                    localpart=localpart, default_display_name=user_display_name
+                )
+
+            self._auth_handler.complete_sso_login(
+                registered_user_id, request, client_redirect_url
+            )
diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py
index 59593cbf6e..4de2f97d06 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(
+            {"redirectUrl": client_redirect_url}
+        ).encode("ascii")
 
 
 class CasTicketServlet(RestServlet):
@@ -436,10 +438,20 @@ class CasTicketServlet(RestServlet):
         self._cas_handler = hs.get_cas_handler()
 
     async def on_GET(self, request: SynapseRequest) -> None:
-        client_redirect_url = parse_string(request, "redirectUrl", required=True)
+        client_redirect_url = parse_string(request, "redirectUrl")
         ticket = parse_string(request, "ticket", required=True)
-        await self._cas_handler.handle_ticket_request(
-            request, client_redirect_url, ticket
+
+        # Maybe get a session ID (if this ticket is from user interactive
+        # authentication).
+        session = parse_string(request, "session")
+
+        # Either client_redirect_url or session must be provided.
+        if not client_redirect_url and not session:
+            message = "Missing string query parameter redirectUrl or session"
+            raise SynapseError(400, message, errcode=Codes.MISSING_PARAM)
+
+        await self._cas_handler.handle_ticket(
+            request, ticket, client_redirect_url, session
         )
 
 
diff --git a/synapse/rest/client/v2_alpha/auth.py b/synapse/rest/client/v2_alpha/auth.py
index 1787562b90..13f9604407 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(
+                    {"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")