From dc3e58693816b897c612ea2b1d5a9f0656108d7d Mon Sep 17 00:00:00 2001 From: Alexander Trost Date: Sun, 2 Jun 2019 18:13:20 +0200 Subject: [PATCH 01/11] SAML2 Improvements and redirect stuff Signed-off-by: Alexander Trost --- synapse/api/constants.py | 1 + synapse/config/saml2_config.py | 1 + synapse/handlers/auth.py | 3 ++ synapse/rest/client/v1/login.py | 46 +++++++++++++++++++++++++ synapse/static/client/login/js/login.js | 6 ++-- 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index ee129c8689..7444434048 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -57,6 +57,7 @@ class LoginType(object): EMAIL_IDENTITY = u"m.login.email.identity" MSISDN = u"m.login.msisdn" RECAPTCHA = u"m.login.recaptcha" + SSO = u"m.login.sso" TERMS = u"m.login.terms" DUMMY = u"m.login.dummy" diff --git a/synapse/config/saml2_config.py b/synapse/config/saml2_config.py index aa6eac271f..60384d33ff 100644 --- a/synapse/config/saml2_config.py +++ b/synapse/config/saml2_config.py @@ -75,6 +75,7 @@ class SAML2Config(Config): # override them. # #saml2_config: + # enabled: true # sp_config: # # point this to the IdP's metadata. You can use either a local file or # # (preferably) a URL. diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index aa5d89a9ac..e6c8965a9d 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -727,6 +727,9 @@ class AuthHandler(BaseHandler): if canonical_user_id: defer.returnValue((canonical_user_id, None)) + if login_type == LoginType.SSO: + known_login_type = True + if not known_login_type: raise SynapseError(400, "Unknown login type %s" % login_type) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index 029039c162..ae9bbba619 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -33,6 +33,9 @@ from synapse.rest.well_known import WellKnownBuilder from synapse.types import UserID, map_username_to_mxid_localpart from synapse.util.msisdn import phone_number_to_msisdn +import saml2 +from saml2.client import Saml2Client + from .base import ClientV1RestServlet, client_path_patterns logger = logging.getLogger(__name__) @@ -93,6 +96,7 @@ class LoginRestServlet(ClientV1RestServlet): self.jwt_enabled = hs.config.jwt_enabled self.jwt_secret = hs.config.jwt_secret self.jwt_algorithm = hs.config.jwt_algorithm + self.saml2_enabled = hs.config.saml2_enabled self.cas_enabled = hs.config.cas_enabled self.auth_handler = self.hs.get_auth_handler() self.registration_handler = hs.get_registration_handler() @@ -104,6 +108,9 @@ class LoginRestServlet(ClientV1RestServlet): flows = [] if self.jwt_enabled: flows.append({"type": LoginRestServlet.JWT_TYPE}) + if self.saml2_enabled: + flows.append({"type": LoginRestServlet.SSO_TYPE}) + flows.append({"type": LoginRestServlet.TOKEN_TYPE}) if self.cas_enabled: flows.append({"type": LoginRestServlet.SSO_TYPE}) @@ -474,6 +481,43 @@ class CasTicketServlet(ClientV1RestServlet): return user, attributes +class SSORedirectServlet(RestServlet): + PATTERNS = client_path_patterns("/login/sso/redirect") + + def __init__(self, hs): + super(SSORedirectServlet, self).__init__() + self.saml2_sp_config = hs.config.saml2_sp_config + + def on_GET(self, request): + args = request.args + + saml_client = Saml2Client(self.saml2_sp_config) + reqid, info = saml_client.prepare_for_authenticate() + + redirect_url = None + + # Select the IdP URL to send the AuthN request to + for key, value in info['headers']: + if key is 'Location': + redirect_url = value + + if redirect_url is None: + raise LoginError(401, "Unsuccessful SSO SAML2 redirect url response", + errcode=Codes.UNAUTHORIZED) + + relay_state = "/_matrix/client/r0/login" + if b"redirectUrl" in args: + relay_state = args[b"redirectUrl"][0] + + url_parts = list(urllib.parse.urlparse(redirect_url)) + query = dict(urllib.parse.parse_qsl(url_parts[4])) + query.update({"RelayState": relay_state}) + url_parts[4] = urllib.parse.urlencode(query) + + request.redirect(urllib.parse.urlunparse(url_parts)) + finish_request(request) + + class SSOAuthHandler(object): """ Utility class for Resources and Servlets which handle the response from a SSO @@ -549,3 +593,5 @@ def register_servlets(hs, http_server): if hs.config.cas_enabled: CasRedirectServlet(hs).register(http_server) CasTicketServlet(hs).register(http_server) + if hs.config.saml2_enabled: + SSORedirectServlet(hs).register(http_server) diff --git a/synapse/static/client/login/js/login.js b/synapse/static/client/login/js/login.js index e02663f50e..9b9e73c41b 100644 --- a/synapse/static/client/login/js/login.js +++ b/synapse/static/client/login/js/login.js @@ -56,6 +56,7 @@ var show_login = function() { } if (matrixLogin.serverAcceptsSso) { + $("#sso_form").attr("action", "/_matrix/client/r0/login/sso/redirect"); $("#sso_flow").show(); } else if (matrixLogin.serverAcceptsCas) { $("#sso_form").attr("action", "/_matrix/client/r0/login/cas/redirect"); @@ -79,7 +80,7 @@ var fetch_info = function(cb) { $.get(matrixLogin.endpoint, function(response) { var serverAcceptsPassword = false; var serverAcceptsCas = false; - for (var i=0; i Date: Tue, 11 Jun 2019 00:03:57 +0100 Subject: [PATCH 02/11] Code cleanups and simplifications. Also: share the saml client between redirect and response handlers. --- synapse/api/constants.py | 1 - synapse/config/saml2_config.py | 7 ++- synapse/handlers/auth.py | 3 - synapse/rest/client/v1/login.py | 83 ++++++++++++------------- synapse/rest/saml2/response_resource.py | 4 +- synapse/server.py | 5 ++ 6 files changed, 53 insertions(+), 50 deletions(-) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index 7444434048..ee129c8689 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -57,7 +57,6 @@ class LoginType(object): EMAIL_IDENTITY = u"m.login.email.identity" MSISDN = u"m.login.msisdn" RECAPTCHA = u"m.login.recaptcha" - SSO = u"m.login.sso" TERMS = u"m.login.terms" DUMMY = u"m.login.dummy" diff --git a/synapse/config/saml2_config.py b/synapse/config/saml2_config.py index 60384d33ff..a6ff62df09 100644 --- a/synapse/config/saml2_config.py +++ b/synapse/config/saml2_config.py @@ -12,6 +12,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from synapse.python_dependencies import DependencyException, check_requirements from ._base import Config, ConfigError @@ -25,6 +26,11 @@ class SAML2Config(Config): if not saml2_config or not saml2_config.get("enabled", True): return + try: + check_requirements('saml2') + except DependencyException as e: + raise ConfigError(e.message) + self.saml2_enabled = True import saml2.config @@ -75,7 +81,6 @@ class SAML2Config(Config): # override them. # #saml2_config: - # enabled: true # sp_config: # # point this to the IdP's metadata. You can use either a local file or # # (preferably) a URL. diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index cb22869e33..7f8ddc99c6 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -767,9 +767,6 @@ class AuthHandler(BaseHandler): if canonical_user_id: defer.returnValue((canonical_user_id, None)) - if login_type == LoginType.SSO: - known_login_type = True - if not known_login_type: raise SynapseError(400, "Unknown login type %s" % login_type) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index 4719712259..1a886cbbbf 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -34,10 +34,6 @@ from synapse.rest.well_known import WellKnownBuilder from synapse.types import UserID, map_username_to_mxid_localpart from synapse.util.msisdn import phone_number_to_msisdn -import saml2 -from saml2.client import Saml2Client - - logger = logging.getLogger(__name__) @@ -378,28 +374,49 @@ class LoginRestServlet(RestServlet): defer.returnValue(result) -class CasRedirectServlet(RestServlet): +class BaseSsoRedirectServlet(RestServlet): + """Common base class for /login/sso/redirect impls""" PATTERNS = client_patterns("/login/(cas|sso)/redirect", v1=True) + def on_GET(self, request): + args = request.args + if b"redirectUrl" not in args: + return 400, "Redirect URL not specified for SSO auth" + client_redirect_url = args[b"redirectUrl"][0] + sso_url = self.get_sso_url(client_redirect_url) + request.redirect(sso_url) + finish_request(request) + + def get_sso_url(self, client_redirect_url): + """Get the URL to redirect to, to perform SSO auth + + Args: + client_redirect_url (bytes): the URL that we should redirect the + client to when everything is done + + Returns: + bytes: URL to redirect to + """ + # to be implemented by subclasses + raise NotImplementedError() + + +class CasRedirectServlet(RestServlet): def __init__(self, hs): super(CasRedirectServlet, self).__init__() self.cas_server_url = hs.config.cas_server_url.encode('ascii') self.cas_service_url = hs.config.cas_service_url.encode('ascii') - def on_GET(self, request): - args = request.args - if b"redirectUrl" not in args: - return (400, "Redirect URL not specified for CAS auth") + def get_sso_url(self, client_redirect_url): client_redirect_url_param = urllib.parse.urlencode({ - b"redirectUrl": args[b"redirectUrl"][0] + b"redirectUrl": client_redirect_url }).encode('ascii') hs_redirect_url = (self.cas_service_url + b"/_matrix/client/r0/login/cas/ticket") service_param = urllib.parse.urlencode({ b"service": b"%s?%s" % (hs_redirect_url, client_redirect_url_param) }).encode('ascii') - request.redirect(b"%s/login?%s" % (self.cas_server_url, service_param)) - finish_request(request) + return b"%s/login?%s" % (self.cas_server_url, service_param) class CasTicketServlet(RestServlet): @@ -482,41 +499,23 @@ class CasTicketServlet(RestServlet): return user, attributes -class SSORedirectServlet(RestServlet): +class SAMLRedirectServlet(BaseSsoRedirectServlet): PATTERNS = client_patterns("/login/sso/redirect", v1=True) def __init__(self, hs): - super(SSORedirectServlet, self).__init__() - self.saml2_sp_config = hs.config.saml2_sp_config + self._saml_client = hs.get_saml_client() - def on_GET(self, request): - args = request.args + def get_sso_url(self, client_redirect_url): + reqid, info = self._saml_client.prepare_for_authenticate( + relay_state=client_redirect_url, + ) - saml_client = Saml2Client(self.saml2_sp_config) - reqid, info = saml_client.prepare_for_authenticate() - - redirect_url = None - - # Select the IdP URL to send the AuthN request to for key, value in info['headers']: - if key is 'Location': - redirect_url = value + if key == 'Location': + return value - if redirect_url is None: - raise LoginError(401, "Unsuccessful SSO SAML2 redirect url response", - errcode=Codes.UNAUTHORIZED) - - relay_state = "/_matrix/client/r0/login" - if b"redirectUrl" in args: - relay_state = args[b"redirectUrl"][0] - - url_parts = list(urllib.parse.urlparse(redirect_url)) - query = dict(urllib.parse.parse_qsl(url_parts[4])) - query.update({"RelayState": relay_state}) - url_parts[4] = urllib.parse.urlencode(query) - - request.redirect(urllib.parse.urlunparse(url_parts)) - finish_request(request) + # this shouldn't happen! + raise Exception("prepare_for_authenticate didn't return a Location header") class SSOAuthHandler(object): @@ -594,5 +593,5 @@ def register_servlets(hs, http_server): if hs.config.cas_enabled: CasRedirectServlet(hs).register(http_server) CasTicketServlet(hs).register(http_server) - if hs.config.saml2_enabled: - SSORedirectServlet(hs).register(http_server) + elif hs.config.saml2_enabled: + SAMLRedirectServlet(hs).register(http_server) diff --git a/synapse/rest/saml2/response_resource.py b/synapse/rest/saml2/response_resource.py index 69fb77b322..36ca1333a8 100644 --- a/synapse/rest/saml2/response_resource.py +++ b/synapse/rest/saml2/response_resource.py @@ -16,7 +16,6 @@ import logging import saml2 -from saml2.client import Saml2Client from twisted.web.resource import Resource from twisted.web.server import NOT_DONE_YET @@ -36,8 +35,7 @@ class SAML2ResponseResource(Resource): def __init__(self, hs): Resource.__init__(self) - - self._saml_client = Saml2Client(hs.config.saml2_sp_config) + self._saml_client = hs.get_saml_client() self._sso_auth_handler = SSOAuthHandler(hs) def render_POST(self, request): diff --git a/synapse/server.py b/synapse/server.py index 9229a68a8d..0eb8968674 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -189,6 +189,7 @@ class HomeServer(object): 'registration_handler', 'account_validity_handler', 'event_client_serializer', + 'saml_client', ] REQUIRED_ON_MASTER_STARTUP = [ @@ -522,6 +523,10 @@ class HomeServer(object): def build_event_client_serializer(self): return EventClientSerializer(self) + def build_saml_client(self): + from saml2.client import Saml2Client + return Saml2Client(self.config.saml2_sp_config) + def remove_pusher(self, app_id, push_key, user_id): return self.get_pusherpool().remove_pusher(app_id, push_key, user_id) From 880005c3b794b2bcce41f9fde1ced49eb1ffc67d Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 11 Jun 2019 00:40:31 +0100 Subject: [PATCH 03/11] changelog --- changelog.d/5422.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/5422.feature diff --git a/changelog.d/5422.feature b/changelog.d/5422.feature new file mode 100644 index 0000000000..02ac25a2f4 --- /dev/null +++ b/changelog.d/5422.feature @@ -0,0 +1 @@ +Fully support SAML2 authentication. Contributed by [Alexander Trost](https://github.com/galexrt) - thank you! From 0ade403f55dd9f6cab90e1e4f0a9241940a437f2 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 26 Jun 2019 22:46:23 +0100 Subject: [PATCH 04/11] Revert redundant changes to static js --- synapse/static/client/login/js/login.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/synapse/static/client/login/js/login.js b/synapse/static/client/login/js/login.js index 9b9e73c41b..e02663f50e 100644 --- a/synapse/static/client/login/js/login.js +++ b/synapse/static/client/login/js/login.js @@ -56,7 +56,6 @@ var show_login = function() { } if (matrixLogin.serverAcceptsSso) { - $("#sso_form").attr("action", "/_matrix/client/r0/login/sso/redirect"); $("#sso_flow").show(); } else if (matrixLogin.serverAcceptsCas) { $("#sso_form").attr("action", "/_matrix/client/r0/login/cas/redirect"); @@ -80,7 +79,7 @@ var fetch_info = function(cb) { $.get(matrixLogin.endpoint, function(response) { var serverAcceptsPassword = false; var serverAcceptsCas = false; - for (var i = 0; i < response.flows.length; i++) { + for (var i=0; i Date: Wed, 26 Jun 2019 22:52:02 +0100 Subject: [PATCH 05/11] Move all the saml stuff out to a centralised handler --- synapse/handlers/saml2_handler.py | 86 +++++++++++++++++++++++++ synapse/rest/client/v1/login.py | 13 +--- synapse/rest/saml2/response_resource.py | 35 +--------- synapse/server.py | 12 ++-- 4 files changed, 96 insertions(+), 50 deletions(-) create mode 100644 synapse/handlers/saml2_handler.py diff --git a/synapse/handlers/saml2_handler.py b/synapse/handlers/saml2_handler.py new file mode 100644 index 0000000000..880e6a625f --- /dev/null +++ b/synapse/handlers/saml2_handler.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging + +import saml2 +from saml2.client import Saml2Client + +from synapse.api.errors import CodeMessageException +from synapse.http.servlet import parse_string +from synapse.rest.client.v1.login import SSOAuthHandler + +logger = logging.getLogger(__name__) + + +class Saml2Handler: + def __init__(self, hs): + self._saml_client = Saml2Client(hs.config.saml2_sp_config) + self._sso_auth_handler = SSOAuthHandler(hs) + + def handle_redirect_request(self, client_redirect_url): + """Handle an incoming request to /login/sso/redirect + + Args: + client_redirect_url (bytes): the URL that we should redirect the + client to when everything is done + + Returns: + bytes: URL to redirect to + """ + reqid, info = self._saml_client.prepare_for_authenticate( + relay_state=client_redirect_url + ) + + for key, value in info["headers"]: + if key == "Location": + return value + + # this shouldn't happen! + raise Exception("prepare_for_authenticate didn't return a Location header") + + def handle_saml_response(self, request): + """Handle an incoming request to /_matrix/saml2/authn_response + + Args: + request (SynapseRequest): the incoming request from the browser. We'll + respond to it with a redirect. + + Returns: + Deferred[none]: Completes once we have handled the request. + """ + resp_bytes = parse_string(request, "SAMLResponse", required=True) + relay_state = parse_string(request, "RelayState", required=True) + + try: + saml2_auth = self._saml_client.parse_authn_request_response( + resp_bytes, saml2.BINDING_HTTP_POST + ) + except Exception as e: + logger.warning("Exception parsing SAML2 response", exc_info=1) + raise CodeMessageException(400, "Unable to parse SAML2 response: %s" % (e,)) + + if saml2_auth.not_signed: + raise CodeMessageException(400, "SAML2 response was not signed") + + if "uid" not in saml2_auth.ava: + raise CodeMessageException(400, "uid not in SAML2 response") + + username = saml2_auth.ava["uid"][0] + + displayName = saml2_auth.ava.get("displayName", [None])[0] + + return self._sso_auth_handler.on_successful_auth( + username, request, relay_state, user_display_name=displayName + ) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index a31d277935..b59aa3d5c9 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -487,19 +487,10 @@ class SAMLRedirectServlet(BaseSsoRedirectServlet): PATTERNS = client_patterns("/login/sso/redirect", v1=True) def __init__(self, hs): - self._saml_client = hs.get_saml_client() + self._saml_handler = hs.get_saml_handler() def get_sso_url(self, client_redirect_url): - reqid, info = self._saml_client.prepare_for_authenticate( - relay_state=client_redirect_url - ) - - for key, value in info["headers"]: - if key == "Location": - return value - - # this shouldn't happen! - raise Exception("prepare_for_authenticate didn't return a Location header") + return self._saml_handler.handle_redirect_request(client_redirect_url) class SSOAuthHandler(object): diff --git a/synapse/rest/saml2/response_resource.py b/synapse/rest/saml2/response_resource.py index 9ec56d6adb..8ee22473e9 100644 --- a/synapse/rest/saml2/response_resource.py +++ b/synapse/rest/saml2/response_resource.py @@ -13,19 +13,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import logging - -import saml2 from twisted.web.resource import Resource from twisted.web.server import NOT_DONE_YET -from synapse.api.errors import CodeMessageException from synapse.http.server import wrap_html_request_handler -from synapse.http.servlet import parse_string -from synapse.rest.client.v1.login import SSOAuthHandler - -logger = logging.getLogger(__name__) class SAML2ResponseResource(Resource): @@ -35,8 +27,7 @@ class SAML2ResponseResource(Resource): def __init__(self, hs): Resource.__init__(self) - self._saml_client = hs.get_saml_client() - self._sso_auth_handler = SSOAuthHandler(hs) + self._saml_handler = hs.get_saml_handler() def render_POST(self, request): self._async_render_POST(request) @@ -44,26 +35,4 @@ class SAML2ResponseResource(Resource): @wrap_html_request_handler def _async_render_POST(self, request): - resp_bytes = parse_string(request, "SAMLResponse", required=True) - relay_state = parse_string(request, "RelayState", required=True) - - try: - saml2_auth = self._saml_client.parse_authn_request_response( - resp_bytes, saml2.BINDING_HTTP_POST - ) - except Exception as e: - logger.warning("Exception parsing SAML2 response", exc_info=1) - raise CodeMessageException(400, "Unable to parse SAML2 response: %s" % (e,)) - - if saml2_auth.not_signed: - raise CodeMessageException(400, "SAML2 response was not signed") - - if "uid" not in saml2_auth.ava: - raise CodeMessageException(400, "uid not in SAML2 response") - - username = saml2_auth.ava["uid"][0] - - displayName = saml2_auth.ava.get("displayName", [None])[0] - return self._sso_auth_handler.on_successful_auth( - username, request, relay_state, user_display_name=displayName - ) + return self._saml_handler.handle_saml_response(request) diff --git a/synapse/server.py b/synapse/server.py index dbb35c7227..1bc8c08b58 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -194,8 +194,8 @@ class HomeServer(object): "sendmail", "registration_handler", "account_validity_handler", + "saml2_handler", "event_client_serializer", - "saml_client", ] REQUIRED_ON_MASTER_STARTUP = ["user_directory_handler", "stats_handler"] @@ -525,14 +525,14 @@ class HomeServer(object): def build_account_validity_handler(self): return AccountValidityHandler(self) + def build_saml2_handler(self): + from synapse.handlers.saml2_handler import Saml2Handler + + return Saml2Handler(self) + def build_event_client_serializer(self): return EventClientSerializer(self) - def build_saml_client(self): - from saml2.client import Saml2Client - - return Saml2Client(self.config.saml2_sp_config) - def remove_pusher(self, app_id, push_key, user_id): return self.get_pusherpool().remove_pusher(app_id, push_key, user_id) From 36f4953dec97ec1650b7c0bb75905ed907a8cac1 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 26 Jun 2019 23:50:55 +0100 Subject: [PATCH 06/11] Add support for tracking SAML2 sessions. This allows us to correctly handle `allow_unsolicited: False`. --- synapse/config/saml2_config.py | 20 +++++++++++++++- synapse/handlers/saml2_handler.py | 39 +++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/synapse/config/saml2_config.py b/synapse/config/saml2_config.py index 463b5fdd68..965a97837f 100644 --- a/synapse/config/saml2_config.py +++ b/synapse/config/saml2_config.py @@ -27,7 +27,7 @@ class SAML2Config(Config): return try: - check_requirements('saml2') + check_requirements("saml2") except DependencyException as e: raise ConfigError(e.message) @@ -43,6 +43,11 @@ class SAML2Config(Config): if config_path is not None: self.saml2_sp_config.load_file(config_path) + # session lifetime: in milliseconds + self.saml2_session_lifetime = self.parse_duration( + saml2_config.get("saml_session_lifetime", "5m") + ) + def _default_saml_config_dict(self): import saml2 @@ -87,6 +92,13 @@ class SAML2Config(Config): # remote: # - url: https://our_idp/metadata.xml # + # # By default, the user has to go to our login page first. If you'd like to + # # allow IdP-initiated login, set 'allow_unsolicited: True' in an 'sp' + # # section: + # # + # #sp: + # # allow_unsolicited: True + # # # # The rest of sp_config is just used to generate our metadata xml, and you # # may well not need it, depending on your setup. Alternatively you # # may need a whole lot more detail - see the pysaml2 docs! @@ -110,6 +122,12 @@ class SAML2Config(Config): # # separate pysaml2 configuration file: # # # config_path: "%(config_dir_path)s/sp_conf.py" + # + # # the lifetime of a SAML session. This defines how long a user has to + # # complete the authentication process, if allow_unsolicited is unset. + # # The default is 5 minutes. + # # + # # saml_session_lifetime: 5m """ % { "config_dir_path": config_dir_path } diff --git a/synapse/handlers/saml2_handler.py b/synapse/handlers/saml2_handler.py index 880e6a625f..b06d3f172e 100644 --- a/synapse/handlers/saml2_handler.py +++ b/synapse/handlers/saml2_handler.py @@ -14,6 +14,7 @@ # limitations under the License. import logging +import attr import saml2 from saml2.client import Saml2Client @@ -29,6 +30,12 @@ class Saml2Handler: self._saml_client = Saml2Client(hs.config.saml2_sp_config) self._sso_auth_handler = SSOAuthHandler(hs) + # a map from saml session id to Saml2SessionData object + self._outstanding_requests_dict = {} + + self._clock = hs.get_clock() + self._saml2_session_lifetime = hs.config.saml2_session_lifetime + def handle_redirect_request(self, client_redirect_url): """Handle an incoming request to /login/sso/redirect @@ -43,6 +50,9 @@ class Saml2Handler: relay_state=client_redirect_url ) + now = self._clock.time_msec() + self._outstanding_requests_dict[reqid] = Saml2SessionData(creation_time=now) + for key, value in info["headers"]: if key == "Location": return value @@ -63,9 +73,15 @@ class Saml2Handler: 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 + resp_bytes, + saml2.BINDING_HTTP_POST, + outstanding=self._outstanding_requests_dict, ) except Exception as e: logger.warning("Exception parsing SAML2 response", exc_info=1) @@ -77,10 +93,29 @@ class Saml2Handler: if "uid" not in saml2_auth.ava: raise CodeMessageException(400, "uid not in SAML2 response") - username = saml2_auth.ava["uid"][0] + self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None) + username = saml2_auth.ava["uid"][0] displayName = saml2_auth.ava.get("displayName", [None])[0] return self._sso_auth_handler.on_successful_auth( username, request, relay_state, user_display_name=displayName ) + + def expire_sessions(self): + expire_before = self._clock.time_msec() - self._saml2_session_lifetime + to_expire = set() + for reqid, data in self._outstanding_requests_dict.items(): + if data.creation_time < expire_before: + to_expire.add(reqid) + for reqid in to_expire: + logger.debug("Expiring session id %s", reqid) + del self._outstanding_requests_dict[reqid] + + +@attr.s +class Saml2SessionData: + """Data we track about SAML2 sessions""" + + # time the session was created, in milliseconds + creation_time = attr.ib() From a0acfcc73eba167fa0cdbcb76d868340ce147aa4 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 26 Jun 2019 23:56:28 +0100 Subject: [PATCH 07/11] update sample config --- docs/sample_config.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index da10788e96..8d52d17792 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -997,6 +997,13 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key" # remote: # - url: https://our_idp/metadata.xml # +# # By default, the user has to go to our login page first. If you'd like to +# # allow IdP-initiated login, set 'allow_unsolicited: True' in an 'sp' +# # section: +# # +# #sp: +# # allow_unsolicited: True +# # # # The rest of sp_config is just used to generate our metadata xml, and you # # may well not need it, depending on your setup. Alternatively you # # may need a whole lot more detail - see the pysaml2 docs! @@ -1020,6 +1027,12 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key" # # separate pysaml2 configuration file: # # # config_path: "CONFDIR/sp_conf.py" +# +# # the lifetime of a SAML session. This defines how long a user has to +# # complete the authentication process, if allow_unsolicited is unset. +# # The default is 5 minutes. +# # +# # saml_session_lifetime: 5m From 28db0ae5377ca8e7133957008ba35b49432636ee Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 27 Jun 2019 00:37:41 +0100 Subject: [PATCH 08/11] cleanups --- synapse/config/saml2_config.py | 19 +++++++++++++------ .../{saml2_handler.py => saml_handler.py} | 2 +- synapse/server.py | 8 ++++---- 3 files changed, 18 insertions(+), 11 deletions(-) rename synapse/handlers/{saml2_handler.py => saml_handler.py} (99%) diff --git a/synapse/config/saml2_config.py b/synapse/config/saml2_config.py index 965a97837f..6a8161547a 100644 --- a/synapse/config/saml2_config.py +++ b/synapse/config/saml2_config.py @@ -83,6 +83,12 @@ class SAML2Config(Config): # so it is not normally necessary to specify them unless you need to # override them. # + # Once SAML support is enabled, a metadata file will be exposed at + # https://:/_matrix/saml2/metadata.xml, which you may be able to + # use to configure your SAML IdP with. Alternatively, you can manually configure + # the IdP to use an ACS location of + # https://:/_matrix/saml2/authn_response. + # #saml2_config: # sp_config: # # point this to the IdP's metadata. You can use either a local file or @@ -93,13 +99,14 @@ class SAML2Config(Config): # - url: https://our_idp/metadata.xml # # # By default, the user has to go to our login page first. If you'd like to - # # allow IdP-initiated login, set 'allow_unsolicited: True' in an 'sp' - # # section: + # # allow IdP-initiated login, set 'allow_unsolicited: True' in a + # # 'service.sp' section: # # - # #sp: - # # allow_unsolicited: True - # # - # # The rest of sp_config is just used to generate our metadata xml, and you + # #service: + # # sp: + # # allow_unsolicited: True + # + # # The examples below are just used to generate our metadata xml, and you # # may well not need it, depending on your setup. Alternatively you # # may need a whole lot more detail - see the pysaml2 docs! # diff --git a/synapse/handlers/saml2_handler.py b/synapse/handlers/saml_handler.py similarity index 99% rename from synapse/handlers/saml2_handler.py rename to synapse/handlers/saml_handler.py index b06d3f172e..03a0ac4384 100644 --- a/synapse/handlers/saml2_handler.py +++ b/synapse/handlers/saml_handler.py @@ -25,7 +25,7 @@ from synapse.rest.client.v1.login import SSOAuthHandler logger = logging.getLogger(__name__) -class Saml2Handler: +class SamlHandler: def __init__(self, hs): self._saml_client = Saml2Client(hs.config.saml2_sp_config) self._sso_auth_handler = SSOAuthHandler(hs) diff --git a/synapse/server.py b/synapse/server.py index 1bc8c08b58..9e28dba2b1 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -194,7 +194,7 @@ class HomeServer(object): "sendmail", "registration_handler", "account_validity_handler", - "saml2_handler", + "saml_handler", "event_client_serializer", ] @@ -525,10 +525,10 @@ class HomeServer(object): def build_account_validity_handler(self): return AccountValidityHandler(self) - def build_saml2_handler(self): - from synapse.handlers.saml2_handler import Saml2Handler + def build_saml_handler(self): + from synapse.handlers.saml_handler import SamlHandler - return Saml2Handler(self) + return SamlHandler(self) def build_event_client_serializer(self): return EventClientSerializer(self) From dde41183417fedf28f47af1fed310790de11c661 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 27 Jun 2019 00:41:04 +0100 Subject: [PATCH 09/11] update sample config --- docs/sample_config.yaml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index 8d52d17792..5558b3e2bd 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -988,6 +988,12 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key" # so it is not normally necessary to specify them unless you need to # override them. # +# Once SAML support is enabled, a metadata file will be exposed at +# https://:/_matrix/saml2/metadata.xml, which you may be able to +# use to configure your SAML IdP with. Alternatively, you can manually configure +# the IdP to use an ACS location of +# https://:/_matrix/saml2/authn_response. +# #saml2_config: # sp_config: # # point this to the IdP's metadata. You can use either a local file or @@ -998,13 +1004,14 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key" # - url: https://our_idp/metadata.xml # # # By default, the user has to go to our login page first. If you'd like to -# # allow IdP-initiated login, set 'allow_unsolicited: True' in an 'sp' -# # section: +# # allow IdP-initiated login, set 'allow_unsolicited: True' in a +# # 'service.sp' section: # # -# #sp: -# # allow_unsolicited: True -# # -# # The rest of sp_config is just used to generate our metadata xml, and you +# #service: +# # sp: +# # allow_unsolicited: True +# +# # The examples below are just used to generate our metadata xml, and you # # may well not need it, depending on your setup. Alternatively you # # may need a whole lot more detail - see the pysaml2 docs! # From 79b9d9076dc37c259fe1682878ef5ebd8a73f1d4 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 27 Jun 2019 00:46:57 +0100 Subject: [PATCH 10/11] rename BaseSSORedirectServlet for consistency --- synapse/rest/client/v1/login.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index b59aa3d5c9..18aa35a7f9 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -358,7 +358,7 @@ class LoginRestServlet(RestServlet): defer.returnValue(result) -class BaseSsoRedirectServlet(RestServlet): +class BaseSSORedirectServlet(RestServlet): """Common base class for /login/sso/redirect impls""" PATTERNS = client_patterns("/login/(cas|sso)/redirect", v1=True) @@ -386,7 +386,7 @@ class BaseSsoRedirectServlet(RestServlet): raise NotImplementedError() -class CasRedirectServlet(BaseSsoRedirectServlet): +class CasRedirectServlet(BaseSSORedirectServlet): def __init__(self, hs): super(CasRedirectServlet, self).__init__() self.cas_server_url = hs.config.cas_server_url.encode("ascii") @@ -483,7 +483,7 @@ class CasTicketServlet(RestServlet): return user, attributes -class SAMLRedirectServlet(BaseSsoRedirectServlet): +class SAMLRedirectServlet(BaseSSORedirectServlet): PATTERNS = client_patterns("/login/sso/redirect", v1=True) def __init__(self, hs): From 3bcb13edd098ae634946d213472a2caf5134b9a8 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 1 Jul 2019 12:13:22 +0100 Subject: [PATCH 11/11] Address review comments --- synapse/handlers/saml_handler.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/synapse/handlers/saml_handler.py b/synapse/handlers/saml_handler.py index 03a0ac4384..a1ce6929cf 100644 --- a/synapse/handlers/saml_handler.py +++ b/synapse/handlers/saml_handler.py @@ -18,7 +18,7 @@ import attr import saml2 from saml2.client import Saml2Client -from synapse.api.errors import CodeMessageException +from synapse.api.errors import SynapseError from synapse.http.servlet import parse_string from synapse.rest.client.v1.login import SSOAuthHandler @@ -84,14 +84,16 @@ class SamlHandler: outstanding=self._outstanding_requests_dict, ) except Exception as e: - logger.warning("Exception parsing SAML2 response", exc_info=1) - raise CodeMessageException(400, "Unable to parse SAML2 response: %s" % (e,)) + logger.warning("Exception parsing SAML2 response: %s", e) + raise SynapseError(400, "Unable to parse SAML2 response: %s" % (e,)) if saml2_auth.not_signed: - raise CodeMessageException(400, "SAML2 response was not signed") + logger.warning("SAML2 response was not signed") + raise SynapseError(400, "SAML2 response was not signed") if "uid" not in saml2_auth.ava: - raise CodeMessageException(400, "uid not in SAML2 response") + logger.warning("SAML2 response lacks a 'uid' attestation") + raise SynapseError(400, "uid not in SAML2 response") self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)