From 81682d0f820a6209535267a45ee28b8f66ff7794 Mon Sep 17 00:00:00 2001 From: Muthu Subramanian Date: Tue, 7 Jul 2015 17:40:30 +0530 Subject: [PATCH 1/5] Integrate SAML2 basic authentication - uses pysaml2 --- synapse/config/homeserver.py | 6 ++-- synapse/config/saml2.py | 27 ++++++++++++++ synapse/handlers/register.py | 30 ++++++++++++++++ synapse/python_dependencies.py | 1 + synapse/rest/client/v1/login.py | 62 ++++++++++++++++++++++++++++++++- 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 synapse/config/saml2.py diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index fe0ccb6eb769..5c655c5373c7 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -25,12 +25,12 @@ from .metrics import MetricsConfig from .appservice import AppServiceConfig from .key import KeyConfig - +from .saml2 import SAML2Config class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, RatelimitConfig, ContentRepositoryConfig, CaptchaConfig, - VoipConfig, RegistrationConfig, - MetricsConfig, AppServiceConfig, KeyConfig,): + VoipConfig, RegistrationConfig, MetricsConfig, + AppServiceConfig, KeyConfig, SAML2Config, ): pass diff --git a/synapse/config/saml2.py b/synapse/config/saml2.py new file mode 100644 index 000000000000..4f3a724e2787 --- /dev/null +++ b/synapse/config/saml2.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Ericsson +# +# 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. + +from ._base import Config + +class SAML2Config(Config): + def read_config(self, config): + self.saml2_config = config["saml2_config"] + + def default_config(self, config_dir_path, server_name): + return """ + saml2_config: + config_path: "%s/sp_conf.py" + idp_redirect_url: "http://%s/idp" + """%(config_dir_path, server_name) diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py index 7b68585a1722..4c6c5e297212 100644 --- a/synapse/handlers/register.py +++ b/synapse/handlers/register.py @@ -192,6 +192,36 @@ def check_recaptcha(self, ip, private_key, challenge, response): else: logger.info("Valid captcha entered from %s", ip) + @defer.inlineCallbacks + def register_saml2(self, localpart): + """ + Registers email_id as SAML2 Based Auth. + """ + if urllib.quote(localpart) != localpart: + raise SynapseError( + 400, + "User ID must only contain characters which do not" + " require URL encoding." + ) + user = UserID(localpart, self.hs.hostname) + user_id = user.to_string() + + yield self.check_user_id_is_valid(user_id) + token = self._generate_token(user_id) + try: + yield self.store.register( + user_id=user_id, + token=token, + password_hash=None + ) + yield self.distributor.fire("registered_user", user) + except Exception, e: + yield self.store.add_access_token_to_user(user_id, token) + # Ignore Registration errors + logger.exception(e) + defer.returnValue((user_id, token)) + + @defer.inlineCallbacks def register_email(self, threepidCreds): """ diff --git a/synapse/python_dependencies.py b/synapse/python_dependencies.py index f9e59dd9174b..17587170c865 100644 --- a/synapse/python_dependencies.py +++ b/synapse/python_dependencies.py @@ -31,6 +31,7 @@ "pillow": ["PIL"], "pydenticon": ["pydenticon"], "ujson": ["ujson"], + "pysaml2": ["saml2"], } CONDITIONAL_REQUIREMENTS = { "web_client": { diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index b2257b749df3..dc7615c6f30e 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -20,14 +20,32 @@ from base import ClientV1RestServlet, client_path_pattern import simplejson as json +import cgi +import urllib + +import logging +from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_HTTP_POST +from saml2.metadata import create_metadata_string +from saml2 import config +from saml2.client import Saml2Client +from saml2.httputil import ServiceError +from saml2.samlp import Extensions +from saml2.extension.pefim import SPCertEnc +from saml2.s_utils import rndstr class LoginRestServlet(ClientV1RestServlet): PATTERN = client_path_pattern("/login$") PASS_TYPE = "m.login.password" + SAML2_TYPE = "m.login.saml2" + + def __init__(self, hs): + super(LoginRestServlet, self).__init__(hs) + self.idp_redirect_url = hs.config.saml2_config['idp_redirect_url'] def on_GET(self, request): - return (200, {"flows": [{"type": LoginRestServlet.PASS_TYPE}]}) + return (200, {"flows": [{"type": LoginRestServlet.PASS_TYPE}, {"type": LoginRestServlet.SAML2_TYPE}]}) def on_OPTIONS(self, request): return (200, {}) @@ -39,6 +57,14 @@ def on_POST(self, request): if login_submission["type"] == LoginRestServlet.PASS_TYPE: result = yield self.do_password_login(login_submission) defer.returnValue(result) + elif login_submission["type"] == LoginRestServlet.SAML2_TYPE: + relay_state = "" + if "relay_state" in login_submission: + relay_state = "&RelayState="+urllib.quote(login_submission["relay_state"]) + result = { + "uri": "%s%s"%(self.idp_redirect_url, relay_state) + } + defer.returnValue((200, result)) else: raise SynapseError(400, "Bad login type.") except KeyError: @@ -93,6 +119,39 @@ def on_POST(self, request): "Missing keys. Requires 'email' and 'user_id'." ) +class SAML2RestServlet(ClientV1RestServlet): + PATTERN = client_path_pattern("/login/saml2") + + def __init__(self, hs): + super(SAML2RestServlet, self).__init__(hs) + self.sp_config = hs.config.saml2_config['config_path'] + + @defer.inlineCallbacks + def on_POST(self, request): + saml2_auth = None + try: + conf = config.SPConfig() + conf.load_file(self.sp_config) + SP = Saml2Client(conf) + saml2_auth = SP.parse_authn_request_response(request.args['SAMLResponse'][0], BINDING_HTTP_POST) + except Exception, e: # Not authenticated + logger = logging.getLogger(__name__) + logger.exception(e) + if saml2_auth and saml2_auth.status_ok() and not saml2_auth.not_signed: + username = saml2_auth.name_id.text + handler = self.handlers.registration_handler + (user_id, token) = yield handler.register_saml2(username) + # Forward to the RelayState callback along with ava + if 'RelayState' in request.args: + request.redirect(urllib.unquote(request.args['RelayState'][0])+'?status=authenticated&access_token='+token+'&user_id='+user_id+'&ava='+urllib.quote(json.dumps(saml2_auth.ava))) + request.finish() + defer.returnValue(None) + defer.returnValue((200, {"status":"authenticated", "user_id": user_id, "token": token, "ava":saml2_auth.ava})) + elif 'RelayState' in request.args: + request.redirect(urllib.unquote(request.args['RelayState'][0])+'?status=not_authenticated') + request.finish() + defer.returnValue(None) + defer.returnValue((200, {"status":"not_authenticated"})) def _parse_json(request): try: @@ -106,4 +165,5 @@ def _parse_json(request): def register_servlets(hs, http_server): LoginRestServlet(hs).register(http_server) + SAML2RestServlet(hs).register(http_server) # TODO PasswordResetRestServlet(hs).register(http_server) From 77c5db5977c3fb61d9d2906c6692ee502d477e18 Mon Sep 17 00:00:00 2001 From: Muthu Subramanian Date: Wed, 8 Jul 2015 16:05:20 +0530 Subject: [PATCH 2/5] code beautify --- synapse/rest/client/v1/login.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index dc7615c6f30e..b4c74c4c20a7 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -45,7 +45,8 @@ def __init__(self, hs): self.idp_redirect_url = hs.config.saml2_config['idp_redirect_url'] def on_GET(self, request): - return (200, {"flows": [{"type": LoginRestServlet.PASS_TYPE}, {"type": LoginRestServlet.SAML2_TYPE}]}) + return (200, {"flows": [{"type": LoginRestServlet.PASS_TYPE}, + {"type": LoginRestServlet.SAML2_TYPE}]}) def on_OPTIONS(self, request): return (200, {}) @@ -60,9 +61,10 @@ def on_POST(self, request): elif login_submission["type"] == LoginRestServlet.SAML2_TYPE: relay_state = "" if "relay_state" in login_submission: - relay_state = "&RelayState="+urllib.quote(login_submission["relay_state"]) + relay_state = "&RelayState="+urllib.quote( + login_submission["relay_state"]) result = { - "uri": "%s%s"%(self.idp_redirect_url, relay_state) + "uri": "%s%s" % (self.idp_redirect_url, relay_state) } defer.returnValue((200, result)) else: @@ -119,6 +121,7 @@ def on_POST(self, request): "Missing keys. Requires 'email' and 'user_id'." ) + class SAML2RestServlet(ClientV1RestServlet): PATTERN = client_path_pattern("/login/saml2") @@ -133,25 +136,35 @@ def on_POST(self, request): conf = config.SPConfig() conf.load_file(self.sp_config) SP = Saml2Client(conf) - saml2_auth = SP.parse_authn_request_response(request.args['SAMLResponse'][0], BINDING_HTTP_POST) - except Exception, e: # Not authenticated + saml2_auth = SP.parse_authn_request_response( + request.args['SAMLResponse'][0], BINDING_HTTP_POST) + except Exception, e: # Not authenticated logger = logging.getLogger(__name__) logger.exception(e) - if saml2_auth and saml2_auth.status_ok() and not saml2_auth.not_signed: + if saml2_auth and saml2_auth.status_ok() and not saml2_auth.not_signed: username = saml2_auth.name_id.text handler = self.handlers.registration_handler (user_id, token) = yield handler.register_saml2(username) # Forward to the RelayState callback along with ava if 'RelayState' in request.args: - request.redirect(urllib.unquote(request.args['RelayState'][0])+'?status=authenticated&access_token='+token+'&user_id='+user_id+'&ava='+urllib.quote(json.dumps(saml2_auth.ava))) + request.redirect(urllib.unquote( + request.args['RelayState'][0]) + + '?status=authenticated&access_token=' + + token + '&user_id=' + user_id + '&ava=' + + urllib.quote(json.dumps(saml2_auth.ava))) request.finish() defer.returnValue(None) - defer.returnValue((200, {"status":"authenticated", "user_id": user_id, "token": token, "ava":saml2_auth.ava})) + defer.returnValue((200, {"status": "authenticated", + "user_id": user_id, "token": token, + "ava": saml2_auth.ava})) elif 'RelayState' in request.args: - request.redirect(urllib.unquote(request.args['RelayState'][0])+'?status=not_authenticated') + request.redirect(urllib.unquote( + request.args['RelayState'][0]) + + '?status=not_authenticated') request.finish() defer.returnValue(None) - defer.returnValue((200, {"status":"not_authenticated"})) + defer.returnValue((200, {"status": "not_authenticated"})) + def _parse_json(request): try: From f53bae0c1948a8c0a229e0b20f237f7ff4b1d84c Mon Sep 17 00:00:00 2001 From: Muthu Subramanian Date: Wed, 8 Jul 2015 16:05:46 +0530 Subject: [PATCH 3/5] code beautify --- synapse/config/homeserver.py | 1 + synapse/config/saml2.py | 3 ++- synapse/handlers/register.py | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index 5c655c5373c7..d77f045406fe 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -27,6 +27,7 @@ from .key import KeyConfig from .saml2 import SAML2Config + class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, RatelimitConfig, ContentRepositoryConfig, CaptchaConfig, VoipConfig, RegistrationConfig, MetricsConfig, diff --git a/synapse/config/saml2.py b/synapse/config/saml2.py index 4f3a724e2787..d18d076a8900 100644 --- a/synapse/config/saml2.py +++ b/synapse/config/saml2.py @@ -15,6 +15,7 @@ from ._base import Config + class SAML2Config(Config): def read_config(self, config): self.saml2_config = config["saml2_config"] @@ -24,4 +25,4 @@ def default_config(self, config_dir_path, server_name): saml2_config: config_path: "%s/sp_conf.py" idp_redirect_url: "http://%s/idp" - """%(config_dir_path, server_name) + """ % (config_dir_path, server_name) diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py index 4c6c5e297212..a1288b425280 100644 --- a/synapse/handlers/register.py +++ b/synapse/handlers/register.py @@ -220,7 +220,6 @@ def register_saml2(self, localpart): # Ignore Registration errors logger.exception(e) defer.returnValue((user_id, token)) - @defer.inlineCallbacks def register_email(self, threepidCreds): From d2caa5351aece72b274f78fe81348f715389d421 Mon Sep 17 00:00:00 2001 From: Muthu Subramanian Date: Thu, 9 Jul 2015 12:58:15 +0530 Subject: [PATCH 4/5] code beautify --- synapse/rest/client/v1/login.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index b4c74c4c20a7..b4894497beed 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -20,19 +20,15 @@ from base import ClientV1RestServlet, client_path_pattern import simplejson as json -import cgi import urllib import logging -from saml2 import BINDING_HTTP_REDIRECT from saml2 import BINDING_HTTP_POST -from saml2.metadata import create_metadata_string from saml2 import config from saml2.client import Saml2Client -from saml2.httputil import ServiceError -from saml2.samlp import Extensions -from saml2.extension.pefim import SPCertEnc -from saml2.s_utils import rndstr + + +logger = logging.getLogger(__name__) class LoginRestServlet(ClientV1RestServlet): @@ -137,9 +133,8 @@ def on_POST(self, request): conf.load_file(self.sp_config) SP = Saml2Client(conf) saml2_auth = SP.parse_authn_request_response( - request.args['SAMLResponse'][0], BINDING_HTTP_POST) + request.args['SAMLResponse'][0], BINDING_HTTP_POST) except Exception, e: # Not authenticated - logger = logging.getLogger(__name__) logger.exception(e) if saml2_auth and saml2_auth.status_ok() and not saml2_auth.not_signed: username = saml2_auth.name_id.text From 8cd34dfe955841d7ff3306b84a686e7138aec526 Mon Sep 17 00:00:00 2001 From: Muthu Subramanian Date: Thu, 9 Jul 2015 13:34:47 +0530 Subject: [PATCH 5/5] Make SAML2 optional and add some references/comments --- synapse/config/saml2.py | 14 ++++++++++++++ synapse/rest/client/v1/login.py | 13 +++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/synapse/config/saml2.py b/synapse/config/saml2.py index d18d076a8900..be5176db52fd 100644 --- a/synapse/config/saml2.py +++ b/synapse/config/saml2.py @@ -16,6 +16,19 @@ from ._base import Config +# +# SAML2 Configuration +# Synapse uses pysaml2 libraries for providing SAML2 support +# +# config_path: Path to the sp_conf.py configuration file +# idp_redirect_url: Identity provider URL which will redirect +# the user back to /login/saml2 with proper info. +# +# sp_conf.py file is something like: +# https://github.com/rohe/pysaml2/blob/master/example/sp-repoze/sp_conf.py.example +# +# More information: https://pythonhosted.org/pysaml2/howto/config.html +# class SAML2Config(Config): def read_config(self, config): self.saml2_config = config["saml2_config"] @@ -23,6 +36,7 @@ def read_config(self, config): def default_config(self, config_dir_path, server_name): return """ saml2_config: + enabled: false config_path: "%s/sp_conf.py" idp_redirect_url: "http://%s/idp" """ % (config_dir_path, server_name) diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index b4894497beed..f64f5e990e7c 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -39,10 +39,13 @@ class LoginRestServlet(ClientV1RestServlet): def __init__(self, hs): super(LoginRestServlet, self).__init__(hs) self.idp_redirect_url = hs.config.saml2_config['idp_redirect_url'] + self.saml2_enabled = hs.config.saml2_config['enabled'] def on_GET(self, request): - return (200, {"flows": [{"type": LoginRestServlet.PASS_TYPE}, - {"type": LoginRestServlet.SAML2_TYPE}]}) + flows = [{"type": LoginRestServlet.PASS_TYPE}] + if self.saml2_enabled: + flows.append({"type": LoginRestServlet.SAML2_TYPE}) + return (200, {"flows": flows}) def on_OPTIONS(self, request): return (200, {}) @@ -54,7 +57,8 @@ def on_POST(self, request): if login_submission["type"] == LoginRestServlet.PASS_TYPE: result = yield self.do_password_login(login_submission) defer.returnValue(result) - elif login_submission["type"] == LoginRestServlet.SAML2_TYPE: + elif self.saml2_enabled and (login_submission["type"] == + LoginRestServlet.SAML2_TYPE): relay_state = "" if "relay_state" in login_submission: relay_state = "&RelayState="+urllib.quote( @@ -173,5 +177,6 @@ def _parse_json(request): def register_servlets(hs, http_server): LoginRestServlet(hs).register(http_server) - SAML2RestServlet(hs).register(http_server) + if hs.config.saml2_config['enabled']: + SAML2RestServlet(hs).register(http_server) # TODO PasswordResetRestServlet(hs).register(http_server)