async def handle_ticket( self, request: SynapseRequest, ticket: str, client_redirect_url: Optional[str], session: Optional[str], ) -> None: """ Called once the user has successfully authenticated with the SSO. Validates a CAS ticket sent by the client and completes the auth process. 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. Otherwise, this registers the user if necessary, and then returns a redirect (with a login token) to the client. Args: request: the incoming request from the browser. We'll respond to it with a redirect or an HTML page. ticket: The CAS ticket provided by the client. 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. session: The session parameter from the `/cas/ticket` HTTP request, if given. This should be the UI Auth session id. """ 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) # Pull out the user-agent and IP from the request. user_agent = request.get_user_agent("") ip_address = self.hs.get_ip_from_request(request) # Get the matrix ID from the CAS username. user_id = await self._map_cas_user_to_matrix_user( username, user_display_name, user_agent, ip_address) if session: await self._auth_handler.complete_sso_ui_auth( user_id, session, request, ) else: # If this not a UI auth request than there must be a redirect URL. assert client_redirect_url await self._auth_handler.complete_sso_login( user_id, request, client_redirect_url)
async def handle_submit_username_request(self, request: SynapseRequest, localpart: str, session_id: str) -> None: """Handle a request to the username-picker 'submit' endpoint Will serve an HTTP response to the request. Args: request: HTTP request localpart: localpart requested by the user session_id: ID of the username mapping session, extracted from a cookie """ self._expire_old_sessions() session = self._username_mapping_sessions.get(session_id) if not session: logger.info("Couldn't find session id %s", session_id) raise SynapseError(400, "unknown session") logger.info("[session %s] Registering localpart %s", session_id, localpart) attributes = UserAttributes( localpart=localpart, display_name=session.display_name, emails=session.emails, ) # the following will raise a 400 error if the username has been taken in the # meantime. user_id = await self._register_mapped_user( attributes, session.auth_provider_id, session.remote_user_id, request.get_user_agent(""), request.getClientIP(), ) logger.info("[session %s] Registered userid %s", session_id, user_id) # delete the mapping session and the cookie del self._username_mapping_sessions[session_id] # delete the cookie request.addCookie( USERNAME_MAPPING_SESSION_COOKIE_NAME, b"", expires=b"Thu, 01 Jan 1970 00:00:00 GMT", path=b"/", ) await self._auth_handler.complete_sso_login( user_id, request, session.client_redirect_url, session.extra_login_attributes, )
async def handle_saml_response(self, request: SynapseRequest) -> None: """Handle an incoming request to /_matrix/saml2/authn_response Args: request: the incoming request from the browser. We'll respond to it with a redirect. Returns: Completes once we have handled the request. """ resp_bytes = parse_string(request, "SAMLResponse", required=True) relay_state = parse_string(request, "RelayState", required=True) # expire outstanding sessions before parse_authn_request_response checks # the dict. self.expire_sessions() try: saml2_auth = self._saml_client.parse_authn_request_response( resp_bytes, saml2.BINDING_HTTP_POST, outstanding=self._outstanding_requests_dict, ) except saml2.response.UnsolicitedResponse as e: # the pysaml2 library helpfully logs an ERROR here, but neglects to log # the session ID. I don't really want to put the full text of the exception # in the (user-visible) exception message, so let's log the exception here # so we can track down the session IDs later. logger.warning(str(e)) self._sso_handler.render_error(request, "unsolicited_response", "Unexpected SAML2 login.") return except Exception as e: self._sso_handler.render_error( request, "invalid_response", "Unable to parse SAML2 response: %s." % (e, ), ) return if saml2_auth.not_signed: self._sso_handler.render_error(request, "unsigned_respond", "SAML2 response was not signed.") return logger.debug("SAML2 response: %s", saml2_auth.origxml) for assertion in saml2_auth.assertions: # kibana limits the length of a log field, whereas this is all rather # useful, so split it up. count = 0 for part in chunk_seq(str(assertion), 10000): logger.info("SAML2 assertion: %s%s", "(%i)..." % (count, ) if count else "", part) count += 1 logger.info("SAML2 mapped attributes: %s", saml2_auth.ava) current_session = self._outstanding_requests_dict.pop( saml2_auth.in_response_to, None) # Ensure that the attributes of the logged in user meet the required # attributes. for requirement in self._saml2_attribute_requirements: if not _check_attribute_requirement(saml2_auth.ava, requirement): self._sso_handler.render_error( request, "unauthorised", "You are not authorised to log in here.") return # Pull out the user-agent and IP from the request. user_agent = request.get_user_agent("") ip_address = self.hs.get_ip_from_request(request) # Call the mapper to register/login the user try: user_id = await self._map_saml_response_to_user( saml2_auth, relay_state, user_agent, ip_address) except MappingException as e: logger.exception("Could not map user") self._sso_handler.render_error(request, "mapping_error", str(e)) return # Complete the interactive auth session or the login. if current_session and current_session.ui_auth_session_id: await self._auth_handler.complete_sso_ui_auth( user_id, current_session.ui_auth_session_id, request) else: await self._auth_handler.complete_sso_login( user_id, request, relay_state)
async def handle_oidc_callback(self, request: SynapseRequest) -> None: """Handle an incoming request to /_synapse/oidc/callback Since we might want to display OIDC-related errors in a user-friendly way, we don't raise SynapseError from here. Instead, we call ``self._render_error`` which displays an HTML page for the error. Most of the OpenID Connect logic happens here: - first, we check if there was any error returned by the provider and display it - then we fetch the session cookie, decode and verify it - the ``state`` query parameter should match with the one stored in the session cookie - once we known this session is legit, exchange the code with the provider using the ``token_endpoint`` (see ``_exchange_code``) - once we have the token, use it to either extract the UserInfo from the ``id_token`` (``_parse_id_token``), or use the ``access_token`` to fetch UserInfo from the ``userinfo_endpoint`` (``_fetch_userinfo``) - map those UserInfo to a Matrix user (``_map_userinfo_to_user``) and finish the login Args: request: the incoming request from the browser. """ # The provider might redirect with an error. # In that case, just display it as-is. if b"error" in request.args: # error response from the auth server. see: # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 # https://openid.net/specs/openid-connect-core-1_0.html#AuthError error = request.args[b"error"][0].decode() description = request.args.get(b"error_description", [b""])[0].decode() # Most of the errors returned by the provider could be due by # either the provider misbehaving or Synapse being misconfigured. # The only exception of that is "access_denied", where the user # probably cancelled the login flow. In other cases, log those errors. if error != "access_denied": logger.error("Error from the OIDC provider: %s %s", error, description) self._render_error(request, error, description) return # otherwise, it is presumably a successful response. see: # https://tools.ietf.org/html/rfc6749#section-4.1.2 # Fetch the session cookie session = request.getCookie(SESSION_COOKIE_NAME) # type: Optional[bytes] if session is None: logger.info("No session cookie found") self._render_error(request, "missing_session", "No session cookie found") return # Remove the cookie. There is a good chance that if the callback failed # once, it will fail next time and the code will already be exchanged. # Removing it early avoids spamming the provider with token requests. request.addCookie( SESSION_COOKIE_NAME, b"", path="/_synapse/oidc", expires="Thu, Jan 01 1970 00:00:00 UTC", httpOnly=True, sameSite="lax", ) # Check for the state query parameter if b"state" not in request.args: logger.info("State parameter is missing") self._render_error(request, "invalid_request", "State parameter is missing") return state = request.args[b"state"][0].decode() # Deserialize the session token and verify it. try: ( nonce, client_redirect_url, ui_auth_session_id, ) = self._verify_oidc_session_token(session, state) except MacaroonDeserializationException as e: logger.exception("Invalid session") self._render_error(request, "invalid_session", str(e)) return except MacaroonInvalidSignatureException as e: logger.exception("Could not verify session") self._render_error(request, "mismatching_session", str(e)) return # Exchange the code with the provider if b"code" not in request.args: logger.info("Code parameter is missing") self._render_error(request, "invalid_request", "Code parameter is missing") return logger.debug("Exchanging code") code = request.args[b"code"][0].decode() try: token = await self._exchange_code(code) except OidcError as e: logger.exception("Could not exchange code") self._render_error(request, e.error, e.error_description) return logger.debug("Successfully obtained OAuth2 access token") # Now that we have a token, get the userinfo, either by decoding the # `id_token` or by fetching the `userinfo_endpoint`. if self._uses_userinfo: logger.debug("Fetching userinfo") try: userinfo = await self._fetch_userinfo(token) except Exception as e: logger.exception("Could not fetch userinfo") self._render_error(request, "fetch_error", str(e)) return else: logger.debug("Extracting userinfo from id_token") try: userinfo = await self._parse_id_token(token, nonce=nonce) except Exception as e: logger.exception("Invalid id_token") self._render_error(request, "invalid_token", str(e)) return # Pull out the user-agent and IP from the request. user_agent = request.get_user_agent("") ip_address = self.hs.get_ip_from_request(request) # Call the mapper to register/login the user try: user_id = await self._map_userinfo_to_user( userinfo, token, user_agent, ip_address ) except MappingException as e: logger.exception("Could not map user") self._render_error(request, "mapping_error", str(e)) return # Mapping providers might not have get_extra_attributes: only call this # method if it exists. extra_attributes = None get_extra_attributes = getattr( self._user_mapping_provider, "get_extra_attributes", None ) if get_extra_attributes: extra_attributes = await get_extra_attributes(userinfo, token) # and finally complete the login if ui_auth_session_id: await self._auth_handler.complete_sso_ui_auth( user_id, ui_auth_session_id, request ) else: await self._auth_handler.complete_sso_login( user_id, request, client_redirect_url, extra_attributes )
async def complete_sso_login_request( self, auth_provider_id: str, remote_user_id: str, request: SynapseRequest, client_redirect_url: str, sso_to_matrix_id_mapper: Callable[[int], Awaitable[UserAttributes]], grandfather_existing_users: Callable[[], Awaitable[Optional[str]]], extra_login_attributes: Optional[JsonDict] = None, ) -> None: """ Given an SSO ID, retrieve the user ID for it and possibly register the user. This first checks if the SSO ID has previously been linked to a matrix ID, if it has that matrix ID is returned regardless of the current mapping logic. If a callable is provided for grandfathering users, it is called and can potentially return a matrix ID to use. If it does, the SSO ID is linked to this matrix ID for subsequent calls. The mapping function is called (potentially multiple times) to generate a localpart for the user. If an unused localpart is generated, the user is registered from the given user-agent and IP address and the SSO ID is linked to this matrix ID for subsequent calls. Finally, we generate a redirect to the supplied redirect uri, with a login token Args: auth_provider_id: A unique identifier for this SSO provider, e.g. "oidc" or "saml". remote_user_id: The unique identifier from the SSO provider. request: The request to respond to client_redirect_url: The redirect URL passed in by the client. sso_to_matrix_id_mapper: A callable to generate the user attributes. The only parameter is an integer which represents the amount of times the returned mxid localpart mapping has failed. It is expected that the mapper can raise two exceptions, which will get passed through to the caller: MappingException if there was a problem mapping the response to the user. RedirectException to redirect to an additional page (e.g. to prompt the user for more information). grandfather_existing_users: A callable which can return an previously existing matrix ID. The SSO ID is then linked to the returned matrix ID. extra_login_attributes: An optional dictionary of extra attributes to be provided to the client in the login response. Raises: MappingException if there was a problem mapping the response to a user. RedirectException: if the mapping provider needs to redirect the user to an additional page. (e.g. to prompt for more information) """ # grab a lock while we try to find a mapping for this user. This seems... # optimistic, especially for implementations that end up redirecting to # interstitial pages. with await self._mapping_lock.queue(auth_provider_id): # first of all, check if we already have a mapping for this user user_id = await self.get_sso_user_by_remote_user_id( auth_provider_id, remote_user_id, ) # Check for grandfathering of users. if not user_id: user_id = await grandfather_existing_users() if user_id: # Future logins should also match this user ID. await self._store.record_user_external_id( auth_provider_id, remote_user_id, user_id) # Otherwise, generate a new user. if not user_id: attributes = await self._call_attribute_mapper( sso_to_matrix_id_mapper) if attributes.localpart is None: # the mapper doesn't return a username. bail out with a redirect to # the username picker. await self._redirect_to_username_picker( auth_provider_id, remote_user_id, attributes, client_redirect_url, extra_login_attributes, ) user_id = await self._register_mapped_user( attributes, auth_provider_id, remote_user_id, request.get_user_agent(""), request.getClientIP(), ) await self._auth_handler.complete_sso_login(user_id, request, client_redirect_url, extra_login_attributes)
async def handle_ticket( self, request: SynapseRequest, ticket: str, client_redirect_url: Optional[str], session: Optional[str], ) -> None: """ Called once the user has successfully authenticated with the SSO. Validates a CAS ticket sent by the client and completes the auth process. 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. Otherwise, this registers the user if necessary, and then returns a redirect (with a login token) to the client. Args: request: the incoming request from the browser. We'll respond to it with a redirect or an HTML page. ticket: The CAS ticket provided by the client. 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. session: The session parameter from the `/cas/ticket` HTTP request, if given. This should be the UI Auth session id. """ 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) 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 session: await self._auth_handler.complete_sso_ui_auth( registered_user_id, session, request, ) else: if not registered_user_id: # Pull out the user-agent and IP from the request. user_agent = request.get_user_agent("") ip_address = self.hs.get_ip_from_request(request) registered_user_id = await self._registration_handler.register_user( localpart=localpart, default_display_name=user_display_name, user_agent_ips=(user_agent, ip_address), ) await self._auth_handler.complete_sso_login( registered_user_id, request, client_redirect_url )