Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Complete the SAML2 implementation #5422

Merged
merged 15 commits into from
Jul 2, 2019
Merged
1 change: 1 addition & 0 deletions changelog.d/5422.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fully support SAML2 authentication. Contributed by [Alexander Trost](https://github.com/galexrt) - thank you!
22 changes: 21 additions & 1 deletion docs/sample_config.yaml
Original file line number Diff line number Diff line change
@@ -997,6 +997,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://<server>:<port>/_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://<server>:<port>/_matrix/saml2/authn_response.
#
#saml2_config:
# sp_config:
# # point this to the IdP's metadata. You can use either a local file or
@@ -1006,7 +1012,15 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key"
# remote:
# - url: https://our_idp/metadata.xml
#
# # The rest of sp_config is just used to generate our metadata xml, and you
# # 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 a
# # 'service.sp' section:
# #
# #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!
#
@@ -1029,6 +1043,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



33 changes: 32 additions & 1 deletion synapse/config/saml2_config.py
Original file line number Diff line number Diff line change
@@ -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 @@ def read_config(self, config, **kwargs):
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
@@ -37,6 +43,11 @@ def read_config(self, config, **kwargs):
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

@@ -72,6 +83,12 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
# 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://<server>:<port>/_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://<server>:<port>/_matrix/saml2/authn_response.
#
#saml2_config:
# sp_config:
# # point this to the IdP's metadata. You can use either a local file or
@@ -81,7 +98,15 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
# remote:
# - url: https://our_idp/metadata.xml
#
# # The rest of sp_config is just used to generate our metadata xml, and you
# # 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 a
# # 'service.sp' section:
# #
# #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!
#
@@ -104,6 +129,12 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
# # 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
}
123 changes: 123 additions & 0 deletions synapse/handlers/saml_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# -*- 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 attr
import saml2
from saml2.client import Saml2Client

from synapse.api.errors import SynapseError
from synapse.http.servlet import parse_string
from synapse.rest.client.v1.login import SSOAuthHandler

logger = logging.getLogger(__name__)


class SamlHandler:
def __init__(self, hs):
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

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
)

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

# this shouldn't happen!
raise Exception("prepare_for_authenticate didn't return a Location header")
erikjohnston marked this conversation as resolved.
Show resolved Hide resolved

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)

# 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 Exception as e:
logger.warning("Exception parsing SAML2 response: %s", e)
raise SynapseError(400, "Unable to parse SAML2 response: %s" % (e,))

if saml2_auth.not_signed:
logger.warning("SAML2 response was not signed")
raise SynapseError(400, "SAML2 response was not signed")

if "uid" not in saml2_auth.ava:
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)

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()
54 changes: 46 additions & 8 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
@@ -86,6 +86,7 @@ def __init__(self, hs):
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()
@@ -97,6 +98,9 @@ def on_GET(self, request):
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})

@@ -354,27 +358,49 @@ def do_jwt_login(self, login_submission):
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(BaseSSORedirectServlet):
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):
@@ -457,6 +483,16 @@ def parse_cas_response(self, cas_response_body):
return user, attributes


class SAMLRedirectServlet(BaseSSORedirectServlet):
PATTERNS = client_patterns("/login/sso/redirect", v1=True)

def __init__(self, hs):
self._saml_handler = hs.get_saml_handler()

def get_sso_url(self, client_redirect_url):
return self._saml_handler.handle_redirect_request(client_redirect_url)


class SSOAuthHandler(object):
"""
Utility class for Resources and Servlets which handle the response from a SSO
@@ -532,3 +568,5 @@ def register_servlets(hs, http_server):
if hs.config.cas_enabled:
CasRedirectServlet(hs).register(http_server)
CasTicketServlet(hs).register(http_server)
elif hs.config.saml2_enabled:
SAMLRedirectServlet(hs).register(http_server)
37 changes: 2 additions & 35 deletions synapse/rest/saml2/response_resource.py
Original file line number Diff line number Diff line change
@@ -13,17 +13,8 @@
# 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.server import DirectServeResource, 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(DirectServeResource):
@@ -33,32 +24,8 @@ class SAML2ResponseResource(DirectServeResource):

def __init__(self, hs):
super().__init__()

self._saml_client = Saml2Client(hs.config.saml2_sp_config)
self._sso_auth_handler = SSOAuthHandler(hs)
self._saml_handler = hs.get_saml_handler()

@wrap_html_request_handler
async 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 await self._saml_handler.handle_saml_response(request)
Loading