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

Commit b2bd54a

Browse files
committed
Add a confirmation step to the SSO login flow
1 parent 3801228 commit b2bd54a

File tree

7 files changed

+245
-6
lines changed

7 files changed

+245
-6
lines changed

docs/sample_config.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,40 @@ saml2_config:
13601360
# # name: value
13611361

13621362

1363+
# Additional settings to use with single-sign on systems such as SAML2 and CAS.
1364+
#
1365+
sso:
1366+
# Directory in which Synapse will try to find the template files below.
1367+
# If not set, default templates from within the Synapse package will be used.
1368+
#
1369+
# DO NOT UNCOMMENT THIS SETTING unless you want to customise the templates.
1370+
# If you *do* uncomment it, you will need to make sure that all the templates
1371+
# below are in the directory.
1372+
#
1373+
# Synapse will look for the following templates in this directory:
1374+
#
1375+
# * HTML page for confirmation of redirect during authentication:
1376+
# 'sso_redirect_confirm.html'.
1377+
#
1378+
# When rendering, this template is given three variables:
1379+
# * redirect_url: the URL the user is about to be redirected to. Needs
1380+
# manual escaping (see
1381+
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
1382+
#
1383+
# * display_url: the same as `redirect_url`, but with the query
1384+
# parameters stripped. The intention is to have a
1385+
# human-readable URL to show to users, not to use it as
1386+
# the final address to redirect to. Needs manual escaping
1387+
# (see https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
1388+
#
1389+
# * server_name: the homeserver's name.
1390+
#
1391+
# You can see the default templates at:
1392+
# https://github.com/matrix-org/synapse/tree/master/synapse/res/templates
1393+
#
1394+
#template_dir: "res/templates"
1395+
1396+
13631397
# The JWT needs to contain a globally unique "sub" (subject) claim.
13641398
#
13651399
#jwt_config:

synapse/config/_base.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ from synapse.config import (
2424
server,
2525
server_notices_config,
2626
spam_checker,
27+
sso,
2728
stats,
2829
third_party_event_rules,
2930
tls,
@@ -57,6 +58,7 @@ class RootConfig:
5758
key: key.KeyConfig
5859
saml2: saml2_config.SAML2Config
5960
cas: cas.CasConfig
61+
sso: sso.SSOConfig
6062
jwt: jwt_config.JWTConfig
6163
password: password.PasswordConfig
6264
email: emailconfig.EmailConfig

synapse/config/homeserver.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .server import ServerConfig
3939
from .server_notices_config import ServerNoticesConfig
4040
from .spam_checker import SpamCheckerConfig
41+
from .sso import SSOConfig
4142
from .stats import StatsConfig
4243
from .third_party_event_rules import ThirdPartyRulesConfig
4344
from .tls import TlsConfig
@@ -65,6 +66,7 @@ class HomeServerConfig(RootConfig):
6566
KeyConfig,
6667
SAML2Config,
6768
CasConfig,
69+
SSOConfig,
6870
JWTConfig,
6971
PasswordConfig,
7072
EmailConfig,

synapse/config/sso.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2020 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+
from typing import Any, Dict
16+
17+
import pkg_resources
18+
19+
from ._base import Config, ConfigError
20+
21+
22+
class SSOConfig(Config):
23+
"""SSO Configuration
24+
"""
25+
26+
section = "sso"
27+
28+
def read_config(self, config, **kwargs):
29+
sso_config = config.get("sso") or {} # type: Dict[str, Any]
30+
31+
# Pick a template directory in order of:
32+
# * The sso-specific template_dir
33+
# * /path/to/synapse/install/res/templates
34+
template_dir = sso_config.get("template_dir")
35+
if not template_dir:
36+
template_dir = pkg_resources.resource_filename("synapse", "res/templates",)
37+
38+
self.sso_redirect_confirm_template_dir = template_dir
39+
40+
def generate_config_section(self, **kwargs):
41+
return """\
42+
# Additional settings to use with single-sign on systems such as SAML2 and CAS.
43+
#
44+
sso:
45+
# Directory in which Synapse will try to find the template files below.
46+
# If not set, default templates from within the Synapse package will be used.
47+
#
48+
# DO NOT UNCOMMENT THIS SETTING unless you want to customise the templates.
49+
# If you *do* uncomment it, you will need to make sure that all the templates
50+
# below are in the directory.
51+
#
52+
# Synapse will look for the following templates in this directory:
53+
#
54+
# * HTML page for a confirmation step before redirecting back to the client
55+
# with the login token: 'sso_redirect_confirm.html'.
56+
#
57+
# When rendering, this template is given three variables:
58+
# * redirect_url: the URL the user is about to be redirected to. Needs
59+
# manual escaping (see
60+
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
61+
#
62+
# * display_url: the same as `redirect_url`, but with the query
63+
# parameters stripped. The intention is to have a
64+
# human-readable URL to show to users, not to use it as
65+
# the final address to redirect to. Needs manual escaping
66+
# (see https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
67+
#
68+
# * server_name: the homeserver's name.
69+
#
70+
# You can see the default templates at:
71+
# https://github.com/matrix-org/synapse/tree/master/synapse/res/templates
72+
#
73+
#template_dir: "res/templates"
74+
"""
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>SSO redirect confirmation</title>
6+
</head>
7+
<body>
8+
<p>The application at <span style="font-weight:bold">{{ display_url | e }}</span> is requesting full access to your <span style="font-weight:bold">{{ server_name }}</span> Matrix account.</p>
9+
<p>If you don't recognise this address, you should ignore this and close this tab.</p>
10+
<p>
11+
<a href="{{ redirect_url | e }}">I trust this address</a>
12+
</p>
13+
</body>
14+
</html>

synapse/rest/client/v1/login.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
parse_string,
3030
)
3131
from synapse.http.site import SynapseRequest
32+
from synapse.push.mailer import load_jinja2_templates
3233
from synapse.rest.client.v2_alpha._base import client_patterns
3334
from synapse.rest.well_known import WellKnownBuilder
3435
from synapse.types import UserID, map_username_to_mxid_localpart
@@ -548,6 +549,13 @@ def __init__(self, hs):
548549
self._registration_handler = hs.get_registration_handler()
549550
self._macaroon_gen = hs.get_macaroon_generator()
550551

552+
# Load the redirect page HTML template
553+
self._template = load_jinja2_templates(
554+
hs.config.sso_redirect_confirm_template_dir, ["sso_redirect_confirm.html"],
555+
)[0]
556+
557+
self._server_name = hs.config.server_name
558+
551559
async def on_successful_auth(
552560
self, username, request, client_redirect_url, user_display_name=None
553561
):
@@ -592,21 +600,41 @@ def complete_sso_login(
592600
request:
593601
client_redirect_url:
594602
"""
595-
603+
# Create a login token
596604
login_token = self._macaroon_gen.generate_short_term_login_token(
597605
registered_user_id
598606
)
599-
redirect_url = self._add_login_token_to_redirect_url(
600-
client_redirect_url, login_token
607+
608+
# Remove the query parameters from the redirect URL to get a shorter version of
609+
# it. This is only to display a human-readable URL in the template, but not the
610+
# URL we redirect users to.
611+
redirect_url_no_params = client_redirect_url.split("?")[0]
612+
613+
# Append the login token to the original redirect URL (i.e. with its query
614+
# parameters kept intact) to build the URL to which the template needs to
615+
# redirect the users once they have clicked on the confirmation link.
616+
redirect_url = self._add_query_param_to_url(
617+
client_redirect_url, "loginToken", login_token
618+
)
619+
620+
# Serve the redirect confirmation page
621+
html = self._template.render(
622+
display_url=redirect_url_no_params,
623+
redirect_url=redirect_url,
624+
server_name=self._server_name,
601625
)
602-
request.redirect(redirect_url)
626+
627+
request.setResponseCode(200)
628+
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
629+
request.setHeader(b"Content-Length", b"%d" % (len(html),))
630+
request.write(html.encode("utf8"))
603631
finish_request(request)
604632

605633
@staticmethod
606-
def _add_login_token_to_redirect_url(url, token):
634+
def _add_query_param_to_url(url, param_name, param):
607635
url_parts = list(urllib.parse.urlparse(url))
608636
query = dict(urllib.parse.parse_qsl(url_parts[4]))
609-
query.update({"loginToken": token})
637+
query.update({param_name: param})
610638
url_parts[4] = urllib.parse.urlencode(query)
611639
return urllib.parse.urlunparse(url_parts)
612640

tests/rest/client/v1/test_login.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import json
2+
import urllib.parse
3+
4+
from mock import Mock
25

36
import synapse.rest.admin
47
from synapse.rest.client.v1 import login
@@ -252,3 +255,85 @@ def _delete_device(self, access_token, user_id, password, device_id):
252255
)
253256
self.render(request)
254257
self.assertEquals(channel.code, 200, channel.result)
258+
259+
260+
class CASRedirectConfirmTestCase(unittest.HomeserverTestCase):
261+
262+
servlets = [
263+
login.register_servlets,
264+
]
265+
266+
def make_homeserver(self, reactor, clock):
267+
self.base_url = "https://matrix.goodserver.com/"
268+
self.redirect_path = "_synapse/client/login/sso/redirect/confirm"
269+
270+
config = self.default_config()
271+
config["enable_registration"] = True
272+
config["cas_config"] = {
273+
"enabled": True,
274+
"server_url": "https://fake.test",
275+
"service_url": "https://matrix.goodserver.com:8448",
276+
}
277+
config["public_baseurl"] = self.base_url
278+
279+
async def get_raw(uri, args):
280+
"""Return an example response payload from a call to the `/proxyValidate`
281+
endpoint of a CAS server, copied from
282+
https://apereo.github.io/cas/5.0.x/protocol/CAS-Protocol-V2-Specification.html#26-proxyvalidate-cas-20
283+
284+
This needs to be returned by an async function (as opposed to set as the
285+
mock's return value) because the corresponding Synapse code awaits on it.
286+
"""
287+
return """
288+
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
289+
<cas:authenticationSuccess>
290+
<cas:user>username</cas:user>
291+
<cas:proxyGrantingTicket>PGTIOU-84678-8a9d...</cas:proxyGrantingTicket>
292+
<cas:proxies>
293+
<cas:proxy>https://proxy2/pgtUrl</cas:proxy>
294+
<cas:proxy>https://proxy1/pgtUrl</cas:proxy>
295+
</cas:proxies>
296+
</cas:authenticationSuccess>
297+
</cas:serviceResponse>
298+
"""
299+
300+
mocked_http_client = Mock(spec=["get_raw"])
301+
mocked_http_client.get_raw.side_effect = get_raw
302+
303+
self.hs = self.setup_test_homeserver(
304+
config=config, proxied_http_client=mocked_http_client,
305+
)
306+
307+
return self.hs
308+
309+
def test_cas_redirect_confirm(self):
310+
"""Tests that the SSO login flow serves a confirmation page before redirecting a
311+
user to the redirect URL.
312+
"""
313+
base_url = "/login/cas/ticket?redirectUrl"
314+
redirect_url = "https://dodgy-site.com/"
315+
316+
url_parts = list(urllib.parse.urlparse(base_url))
317+
query = dict(urllib.parse.parse_qsl(url_parts[4]))
318+
query.update({"redirectUrl": redirect_url})
319+
query.update({"ticket": "ticket"})
320+
url_parts[4] = urllib.parse.urlencode(query)
321+
cas_ticket_url = urllib.parse.urlunparse(url_parts)
322+
323+
# Get Synapse to call the fake CAS and serve the template.
324+
request, channel = self.make_request("GET", cas_ticket_url)
325+
self.render(request)
326+
327+
# Test that the response is HTML.
328+
content_type_header_value = ""
329+
for header in channel.result.get("headers", []):
330+
if header[0] == b"Content-Type":
331+
content_type_header_value = header[1].decode("utf8")
332+
333+
self.assertTrue(content_type_header_value.startswith("text/html"))
334+
335+
# Test that the body isn't empty.
336+
self.assertTrue(len(channel.result["body"]) > 0)
337+
338+
# And that it contains our redirect link
339+
self.assertIn(redirect_url, channel.result["body"].decode("UTF-8"))

0 commit comments

Comments
 (0)