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

Commit 6eecb6e

Browse files
authored
Complete the SAML2 implementation (#5422)
* SAML2 Improvements and redirect stuff Signed-off-by: Alexander Trost <galexrt@googlemail.com> * Code cleanups and simplifications. Also: share the saml client between redirect and response handlers. * changelog * Revert redundant changes to static js * Move all the saml stuff out to a centralised handler * Add support for tracking SAML2 sessions. This allows us to correctly handle `allow_unsolicited: False`. * update sample config * cleanups * update sample config * rename BaseSSORedirectServlet for consistency * Address review comments
2 parents c3863ad + b4fd86a commit 6eecb6e

File tree

7 files changed

+231
-45
lines changed

7 files changed

+231
-45
lines changed

changelog.d/5422.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fully support SAML2 authentication. Contributed by [Alexander Trost](https://github.com/galexrt) - thank you!

docs/sample_config.yaml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,12 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key"
997997
# so it is not normally necessary to specify them unless you need to
998998
# override them.
999999
#
1000+
# Once SAML support is enabled, a metadata file will be exposed at
1001+
# https://<server>:<port>/_matrix/saml2/metadata.xml, which you may be able to
1002+
# use to configure your SAML IdP with. Alternatively, you can manually configure
1003+
# the IdP to use an ACS location of
1004+
# https://<server>:<port>/_matrix/saml2/authn_response.
1005+
#
10001006
#saml2_config:
10011007
# sp_config:
10021008
# # 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"
10061012
# remote:
10071013
# - url: https://our_idp/metadata.xml
10081014
#
1009-
# # The rest of sp_config is just used to generate our metadata xml, and you
1015+
# # By default, the user has to go to our login page first. If you'd like to
1016+
# # allow IdP-initiated login, set 'allow_unsolicited: True' in a
1017+
# # 'service.sp' section:
1018+
# #
1019+
# #service:
1020+
# # sp:
1021+
# # allow_unsolicited: True
1022+
#
1023+
# # The examples below are just used to generate our metadata xml, and you
10101024
# # may well not need it, depending on your setup. Alternatively you
10111025
# # may need a whole lot more detail - see the pysaml2 docs!
10121026
#
@@ -1029,6 +1043,12 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key"
10291043
# # separate pysaml2 configuration file:
10301044
# #
10311045
# config_path: "CONFDIR/sp_conf.py"
1046+
#
1047+
# # the lifetime of a SAML session. This defines how long a user has to
1048+
# # complete the authentication process, if allow_unsolicited is unset.
1049+
# # The default is 5 minutes.
1050+
# #
1051+
# # saml_session_lifetime: 5m
10321052

10331053

10341054

synapse/config/saml2_config.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
15+
from synapse.python_dependencies import DependencyException, check_requirements
1516

1617
from ._base import Config, ConfigError
1718

@@ -25,6 +26,11 @@ def read_config(self, config, **kwargs):
2526
if not saml2_config or not saml2_config.get("enabled", True):
2627
return
2728

29+
try:
30+
check_requirements("saml2")
31+
except DependencyException as e:
32+
raise ConfigError(e.message)
33+
2834
self.saml2_enabled = True
2935

3036
import saml2.config
@@ -37,6 +43,11 @@ def read_config(self, config, **kwargs):
3743
if config_path is not None:
3844
self.saml2_sp_config.load_file(config_path)
3945

46+
# session lifetime: in milliseconds
47+
self.saml2_session_lifetime = self.parse_duration(
48+
saml2_config.get("saml_session_lifetime", "5m")
49+
)
50+
4051
def _default_saml_config_dict(self):
4152
import saml2
4253

@@ -72,6 +83,12 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
7283
# so it is not normally necessary to specify them unless you need to
7384
# override them.
7485
#
86+
# Once SAML support is enabled, a metadata file will be exposed at
87+
# https://<server>:<port>/_matrix/saml2/metadata.xml, which you may be able to
88+
# use to configure your SAML IdP with. Alternatively, you can manually configure
89+
# the IdP to use an ACS location of
90+
# https://<server>:<port>/_matrix/saml2/authn_response.
91+
#
7592
#saml2_config:
7693
# sp_config:
7794
# # 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):
8198
# remote:
8299
# - url: https://our_idp/metadata.xml
83100
#
84-
# # The rest of sp_config is just used to generate our metadata xml, and you
101+
# # By default, the user has to go to our login page first. If you'd like to
102+
# # allow IdP-initiated login, set 'allow_unsolicited: True' in a
103+
# # 'service.sp' section:
104+
# #
105+
# #service:
106+
# # sp:
107+
# # allow_unsolicited: True
108+
#
109+
# # The examples below are just used to generate our metadata xml, and you
85110
# # may well not need it, depending on your setup. Alternatively you
86111
# # may need a whole lot more detail - see the pysaml2 docs!
87112
#
@@ -104,6 +129,12 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
104129
# # separate pysaml2 configuration file:
105130
# #
106131
# config_path: "%(config_dir_path)s/sp_conf.py"
132+
#
133+
# # the lifetime of a SAML session. This defines how long a user has to
134+
# # complete the authentication process, if allow_unsolicited is unset.
135+
# # The default is 5 minutes.
136+
# #
137+
# # saml_session_lifetime: 5m
107138
""" % {
108139
"config_dir_path": config_dir_path
109140
}

synapse/handlers/saml_handler.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2019 The Matrix.org Foundation C.I.C.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
import logging
16+
17+
import attr
18+
import saml2
19+
from saml2.client import Saml2Client
20+
21+
from synapse.api.errors import SynapseError
22+
from synapse.http.servlet import parse_string
23+
from synapse.rest.client.v1.login import SSOAuthHandler
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class SamlHandler:
29+
def __init__(self, hs):
30+
self._saml_client = Saml2Client(hs.config.saml2_sp_config)
31+
self._sso_auth_handler = SSOAuthHandler(hs)
32+
33+
# a map from saml session id to Saml2SessionData object
34+
self._outstanding_requests_dict = {}
35+
36+
self._clock = hs.get_clock()
37+
self._saml2_session_lifetime = hs.config.saml2_session_lifetime
38+
39+
def handle_redirect_request(self, client_redirect_url):
40+
"""Handle an incoming request to /login/sso/redirect
41+
42+
Args:
43+
client_redirect_url (bytes): the URL that we should redirect the
44+
client to when everything is done
45+
46+
Returns:
47+
bytes: URL to redirect to
48+
"""
49+
reqid, info = self._saml_client.prepare_for_authenticate(
50+
relay_state=client_redirect_url
51+
)
52+
53+
now = self._clock.time_msec()
54+
self._outstanding_requests_dict[reqid] = Saml2SessionData(creation_time=now)
55+
56+
for key, value in info["headers"]:
57+
if key == "Location":
58+
return value
59+
60+
# this shouldn't happen!
61+
raise Exception("prepare_for_authenticate didn't return a Location header")
62+
63+
def handle_saml_response(self, request):
64+
"""Handle an incoming request to /_matrix/saml2/authn_response
65+
66+
Args:
67+
request (SynapseRequest): the incoming request from the browser. We'll
68+
respond to it with a redirect.
69+
70+
Returns:
71+
Deferred[none]: Completes once we have handled the request.
72+
"""
73+
resp_bytes = parse_string(request, "SAMLResponse", required=True)
74+
relay_state = parse_string(request, "RelayState", required=True)
75+
76+
# expire outstanding sessions before parse_authn_request_response checks
77+
# the dict.
78+
self.expire_sessions()
79+
80+
try:
81+
saml2_auth = self._saml_client.parse_authn_request_response(
82+
resp_bytes,
83+
saml2.BINDING_HTTP_POST,
84+
outstanding=self._outstanding_requests_dict,
85+
)
86+
except Exception as e:
87+
logger.warning("Exception parsing SAML2 response: %s", e)
88+
raise SynapseError(400, "Unable to parse SAML2 response: %s" % (e,))
89+
90+
if saml2_auth.not_signed:
91+
logger.warning("SAML2 response was not signed")
92+
raise SynapseError(400, "SAML2 response was not signed")
93+
94+
if "uid" not in saml2_auth.ava:
95+
logger.warning("SAML2 response lacks a 'uid' attestation")
96+
raise SynapseError(400, "uid not in SAML2 response")
97+
98+
self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)
99+
100+
username = saml2_auth.ava["uid"][0]
101+
displayName = saml2_auth.ava.get("displayName", [None])[0]
102+
103+
return self._sso_auth_handler.on_successful_auth(
104+
username, request, relay_state, user_display_name=displayName
105+
)
106+
107+
def expire_sessions(self):
108+
expire_before = self._clock.time_msec() - self._saml2_session_lifetime
109+
to_expire = set()
110+
for reqid, data in self._outstanding_requests_dict.items():
111+
if data.creation_time < expire_before:
112+
to_expire.add(reqid)
113+
for reqid in to_expire:
114+
logger.debug("Expiring session id %s", reqid)
115+
del self._outstanding_requests_dict[reqid]
116+
117+
118+
@attr.s
119+
class Saml2SessionData:
120+
"""Data we track about SAML2 sessions"""
121+
122+
# time the session was created, in milliseconds
123+
creation_time = attr.ib()

synapse/rest/client/v1/login.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def __init__(self, hs):
8686
self.jwt_enabled = hs.config.jwt_enabled
8787
self.jwt_secret = hs.config.jwt_secret
8888
self.jwt_algorithm = hs.config.jwt_algorithm
89+
self.saml2_enabled = hs.config.saml2_enabled
8990
self.cas_enabled = hs.config.cas_enabled
9091
self.auth_handler = self.hs.get_auth_handler()
9192
self.registration_handler = hs.get_registration_handler()
@@ -97,6 +98,9 @@ def on_GET(self, request):
9798
flows = []
9899
if self.jwt_enabled:
99100
flows.append({"type": LoginRestServlet.JWT_TYPE})
101+
if self.saml2_enabled:
102+
flows.append({"type": LoginRestServlet.SSO_TYPE})
103+
flows.append({"type": LoginRestServlet.TOKEN_TYPE})
100104
if self.cas_enabled:
101105
flows.append({"type": LoginRestServlet.SSO_TYPE})
102106

@@ -351,27 +355,49 @@ def do_jwt_login(self, login_submission):
351355
defer.returnValue(result)
352356

353357

354-
class CasRedirectServlet(RestServlet):
358+
class BaseSSORedirectServlet(RestServlet):
359+
"""Common base class for /login/sso/redirect impls"""
360+
355361
PATTERNS = client_patterns("/login/(cas|sso)/redirect", v1=True)
356362

363+
def on_GET(self, request):
364+
args = request.args
365+
if b"redirectUrl" not in args:
366+
return 400, "Redirect URL not specified for SSO auth"
367+
client_redirect_url = args[b"redirectUrl"][0]
368+
sso_url = self.get_sso_url(client_redirect_url)
369+
request.redirect(sso_url)
370+
finish_request(request)
371+
372+
def get_sso_url(self, client_redirect_url):
373+
"""Get the URL to redirect to, to perform SSO auth
374+
375+
Args:
376+
client_redirect_url (bytes): the URL that we should redirect the
377+
client to when everything is done
378+
379+
Returns:
380+
bytes: URL to redirect to
381+
"""
382+
# to be implemented by subclasses
383+
raise NotImplementedError()
384+
385+
386+
class CasRedirectServlet(BaseSSORedirectServlet):
357387
def __init__(self, hs):
358388
super(CasRedirectServlet, self).__init__()
359389
self.cas_server_url = hs.config.cas_server_url.encode("ascii")
360390
self.cas_service_url = hs.config.cas_service_url.encode("ascii")
361391

362-
def on_GET(self, request):
363-
args = request.args
364-
if b"redirectUrl" not in args:
365-
return (400, "Redirect URL not specified for CAS auth")
392+
def get_sso_url(self, client_redirect_url):
366393
client_redirect_url_param = urllib.parse.urlencode(
367-
{b"redirectUrl": args[b"redirectUrl"][0]}
394+
{b"redirectUrl": client_redirect_url}
368395
).encode("ascii")
369396
hs_redirect_url = self.cas_service_url + b"/_matrix/client/r0/login/cas/ticket"
370397
service_param = urllib.parse.urlencode(
371398
{b"service": b"%s?%s" % (hs_redirect_url, client_redirect_url_param)}
372399
).encode("ascii")
373-
request.redirect(b"%s/login?%s" % (self.cas_server_url, service_param))
374-
finish_request(request)
400+
return b"%s/login?%s" % (self.cas_server_url, service_param)
375401

376402

377403
class CasTicketServlet(RestServlet):
@@ -454,6 +480,16 @@ def parse_cas_response(self, cas_response_body):
454480
return user, attributes
455481

456482

483+
class SAMLRedirectServlet(BaseSSORedirectServlet):
484+
PATTERNS = client_patterns("/login/sso/redirect", v1=True)
485+
486+
def __init__(self, hs):
487+
self._saml_handler = hs.get_saml_handler()
488+
489+
def get_sso_url(self, client_redirect_url):
490+
return self._saml_handler.handle_redirect_request(client_redirect_url)
491+
492+
457493
class SSOAuthHandler(object):
458494
"""
459495
Utility class for Resources and Servlets which handle the response from a SSO
@@ -529,3 +565,5 @@ def register_servlets(hs, http_server):
529565
if hs.config.cas_enabled:
530566
CasRedirectServlet(hs).register(http_server)
531567
CasTicketServlet(hs).register(http_server)
568+
elif hs.config.saml2_enabled:
569+
SAMLRedirectServlet(hs).register(http_server)

synapse/rest/saml2/response_resource.py

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,8 @@
1313
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
16-
import logging
1716

18-
import saml2
19-
from saml2.client import Saml2Client
20-
21-
from synapse.api.errors import CodeMessageException
2217
from synapse.http.server import DirectServeResource, wrap_html_request_handler
23-
from synapse.http.servlet import parse_string
24-
from synapse.rest.client.v1.login import SSOAuthHandler
25-
26-
logger = logging.getLogger(__name__)
2718

2819

2920
class SAML2ResponseResource(DirectServeResource):
@@ -33,32 +24,8 @@ class SAML2ResponseResource(DirectServeResource):
3324

3425
def __init__(self, hs):
3526
super().__init__()
36-
37-
self._saml_client = Saml2Client(hs.config.saml2_sp_config)
38-
self._sso_auth_handler = SSOAuthHandler(hs)
27+
self._saml_handler = hs.get_saml_handler()
3928

4029
@wrap_html_request_handler
4130
async def _async_render_POST(self, request):
42-
resp_bytes = parse_string(request, "SAMLResponse", required=True)
43-
relay_state = parse_string(request, "RelayState", required=True)
44-
45-
try:
46-
saml2_auth = self._saml_client.parse_authn_request_response(
47-
resp_bytes, saml2.BINDING_HTTP_POST
48-
)
49-
except Exception as e:
50-
logger.warning("Exception parsing SAML2 response", exc_info=1)
51-
raise CodeMessageException(400, "Unable to parse SAML2 response: %s" % (e,))
52-
53-
if saml2_auth.not_signed:
54-
raise CodeMessageException(400, "SAML2 response was not signed")
55-
56-
if "uid" not in saml2_auth.ava:
57-
raise CodeMessageException(400, "uid not in SAML2 response")
58-
59-
username = saml2_auth.ava["uid"][0]
60-
61-
displayName = saml2_auth.ava.get("displayName", [None])[0]
62-
return self._sso_auth_handler.on_successful_auth(
63-
username, request, relay_state, user_display_name=displayName
64-
)
31+
return await self._saml_handler.handle_saml_response(request)

0 commit comments

Comments
 (0)