Merge branch 'anoabolivier/sso-confirm' into 'release-v1.11.1'
Add a confirmation step to the SSO login flow See merge request new-vector/synapse!2
This commit is contained in:
commit
2e7fad87d4
|
@ -1360,6 +1360,40 @@ saml2_config:
|
|||
# # name: value
|
||||
|
||||
|
||||
# Additional settings to use with single-sign on systems such as SAML2 and CAS.
|
||||
#
|
||||
sso:
|
||||
# Directory in which Synapse will try to find the template files below.
|
||||
# If not set, default templates from within the Synapse package will be used.
|
||||
#
|
||||
# DO NOT UNCOMMENT THIS SETTING unless you want to customise the templates.
|
||||
# If you *do* uncomment it, you will need to make sure that all the templates
|
||||
# below are in the directory.
|
||||
#
|
||||
# Synapse will look for the following templates in this directory:
|
||||
#
|
||||
# * HTML page for confirmation of redirect during authentication:
|
||||
# 'sso_redirect_confirm.html'.
|
||||
#
|
||||
# When rendering, this template is given three variables:
|
||||
# * redirect_url: the URL the user is about to be redirected to. Needs
|
||||
# manual escaping (see
|
||||
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||
#
|
||||
# * display_url: the same as `redirect_url`, but with the query
|
||||
# parameters stripped. The intention is to have a
|
||||
# human-readable URL to show to users, not to use it as
|
||||
# the final address to redirect to. Needs manual escaping
|
||||
# (see https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||
#
|
||||
# * server_name: the homeserver's name.
|
||||
#
|
||||
# You can see the default templates at:
|
||||
# https://github.com/matrix-org/synapse/tree/master/synapse/res/templates
|
||||
#
|
||||
#template_dir: "res/templates"
|
||||
|
||||
|
||||
# The JWT needs to contain a globally unique "sub" (subject) claim.
|
||||
#
|
||||
#jwt_config:
|
||||
|
|
|
@ -24,6 +24,7 @@ from synapse.config import (
|
|||
server,
|
||||
server_notices_config,
|
||||
spam_checker,
|
||||
sso,
|
||||
stats,
|
||||
third_party_event_rules,
|
||||
tls,
|
||||
|
@ -57,6 +58,7 @@ class RootConfig:
|
|||
key: key.KeyConfig
|
||||
saml2: saml2_config.SAML2Config
|
||||
cas: cas.CasConfig
|
||||
sso: sso.SSOConfig
|
||||
jwt: jwt_config.JWTConfig
|
||||
password: password.PasswordConfig
|
||||
email: emailconfig.EmailConfig
|
||||
|
|
|
@ -38,6 +38,7 @@ from .saml2_config import SAML2Config
|
|||
from .server import ServerConfig
|
||||
from .server_notices_config import ServerNoticesConfig
|
||||
from .spam_checker import SpamCheckerConfig
|
||||
from .sso import SSOConfig
|
||||
from .stats import StatsConfig
|
||||
from .third_party_event_rules import ThirdPartyRulesConfig
|
||||
from .tls import TlsConfig
|
||||
|
@ -65,6 +66,7 @@ class HomeServerConfig(RootConfig):
|
|||
KeyConfig,
|
||||
SAML2Config,
|
||||
CasConfig,
|
||||
SSOConfig,
|
||||
JWTConfig,
|
||||
PasswordConfig,
|
||||
EmailConfig,
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2020 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.
|
||||
from typing import Any, Dict
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from ._base import Config, ConfigError
|
||||
|
||||
|
||||
class SSOConfig(Config):
|
||||
"""SSO Configuration
|
||||
"""
|
||||
|
||||
section = "sso"
|
||||
|
||||
def read_config(self, config, **kwargs):
|
||||
sso_config = config.get("sso") or {} # type: Dict[str, Any]
|
||||
|
||||
# Pick a template directory in order of:
|
||||
# * The sso-specific template_dir
|
||||
# * /path/to/synapse/install/res/templates
|
||||
template_dir = sso_config.get("template_dir")
|
||||
if not template_dir:
|
||||
template_dir = pkg_resources.resource_filename("synapse", "res/templates",)
|
||||
|
||||
self.sso_redirect_confirm_template_dir = template_dir
|
||||
|
||||
def generate_config_section(self, **kwargs):
|
||||
return """\
|
||||
# Additional settings to use with single-sign on systems such as SAML2 and CAS.
|
||||
#
|
||||
sso:
|
||||
# Directory in which Synapse will try to find the template files below.
|
||||
# If not set, default templates from within the Synapse package will be used.
|
||||
#
|
||||
# DO NOT UNCOMMENT THIS SETTING unless you want to customise the templates.
|
||||
# If you *do* uncomment it, you will need to make sure that all the templates
|
||||
# below are in the directory.
|
||||
#
|
||||
# Synapse will look for the following templates in this directory:
|
||||
#
|
||||
# * HTML page for a confirmation step before redirecting back to the client
|
||||
# with the login token: 'sso_redirect_confirm.html'.
|
||||
#
|
||||
# When rendering, this template is given three variables:
|
||||
# * redirect_url: the URL the user is about to be redirected to. Needs
|
||||
# manual escaping (see
|
||||
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||
#
|
||||
# * display_url: the same as `redirect_url`, but with the query
|
||||
# parameters stripped. The intention is to have a
|
||||
# human-readable URL to show to users, not to use it as
|
||||
# the final address to redirect to. Needs manual escaping
|
||||
# (see https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||
#
|
||||
# * server_name: the homeserver's name.
|
||||
#
|
||||
# You can see the default templates at:
|
||||
# https://github.com/matrix-org/synapse/tree/master/synapse/res/templates
|
||||
#
|
||||
#template_dir: "res/templates"
|
||||
"""
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SSO redirect confirmation</title>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<p>If you don't recognise this address, you should ignore this and close this tab.</p>
|
||||
<p>
|
||||
<a href="{{ redirect_url | e }}">I trust this address</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
|
@ -29,6 +29,7 @@ from synapse.http.servlet import (
|
|||
parse_string,
|
||||
)
|
||||
from synapse.http.site import SynapseRequest
|
||||
from synapse.push.mailer import load_jinja2_templates
|
||||
from synapse.rest.client.v2_alpha._base import client_patterns
|
||||
from synapse.rest.well_known import WellKnownBuilder
|
||||
from synapse.types import UserID, map_username_to_mxid_localpart
|
||||
|
@ -548,6 +549,13 @@ class SSOAuthHandler(object):
|
|||
self._registration_handler = hs.get_registration_handler()
|
||||
self._macaroon_gen = hs.get_macaroon_generator()
|
||||
|
||||
# Load the redirect page HTML template
|
||||
self._template = load_jinja2_templates(
|
||||
hs.config.sso_redirect_confirm_template_dir, ["sso_redirect_confirm.html"],
|
||||
)[0]
|
||||
|
||||
self._server_name = hs.config.server_name
|
||||
|
||||
async def on_successful_auth(
|
||||
self, username, request, client_redirect_url, user_display_name=None
|
||||
):
|
||||
|
@ -592,21 +600,41 @@ class SSOAuthHandler(object):
|
|||
request:
|
||||
client_redirect_url:
|
||||
"""
|
||||
|
||||
# Create a login token
|
||||
login_token = self._macaroon_gen.generate_short_term_login_token(
|
||||
registered_user_id
|
||||
)
|
||||
redirect_url = self._add_login_token_to_redirect_url(
|
||||
client_redirect_url, login_token
|
||||
|
||||
# Remove the query parameters from the redirect URL to get a shorter version of
|
||||
# it. This is only to display a human-readable URL in the template, but not the
|
||||
# URL we redirect users to.
|
||||
redirect_url_no_params = client_redirect_url.split("?")[0]
|
||||
|
||||
# Append the login token to the original redirect URL (i.e. with its query
|
||||
# parameters kept intact) to build the URL to which the template needs to
|
||||
# redirect the users once they have clicked on the confirmation link.
|
||||
redirect_url = self._add_query_param_to_url(
|
||||
client_redirect_url, "loginToken", login_token
|
||||
)
|
||||
request.redirect(redirect_url)
|
||||
|
||||
# Serve the redirect confirmation page
|
||||
html = self._template.render(
|
||||
display_url=redirect_url_no_params,
|
||||
redirect_url=redirect_url,
|
||||
server_name=self._server_name,
|
||||
)
|
||||
|
||||
request.setResponseCode(200)
|
||||
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
|
||||
request.setHeader(b"Content-Length", b"%d" % (len(html),))
|
||||
request.write(html.encode("utf8"))
|
||||
finish_request(request)
|
||||
|
||||
@staticmethod
|
||||
def _add_login_token_to_redirect_url(url, token):
|
||||
def _add_query_param_to_url(url, param_name, param):
|
||||
url_parts = list(urllib.parse.urlparse(url))
|
||||
query = dict(urllib.parse.parse_qsl(url_parts[4]))
|
||||
query.update({"loginToken": token})
|
||||
query.update({param_name: param})
|
||||
url_parts[4] = urllib.parse.urlencode(query)
|
||||
return urllib.parse.urlunparse(url_parts)
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import json
|
||||
import urllib.parse
|
||||
|
||||
from mock import Mock
|
||||
|
||||
import synapse.rest.admin
|
||||
from synapse.rest.client.v1 import login
|
||||
|
@ -252,3 +255,85 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
|
|||
)
|
||||
self.render(request)
|
||||
self.assertEquals(channel.code, 200, channel.result)
|
||||
|
||||
|
||||
class CASRedirectConfirmTestCase(unittest.HomeserverTestCase):
|
||||
|
||||
servlets = [
|
||||
login.register_servlets,
|
||||
]
|
||||
|
||||
def make_homeserver(self, reactor, clock):
|
||||
self.base_url = "https://matrix.goodserver.com/"
|
||||
self.redirect_path = "_synapse/client/login/sso/redirect/confirm"
|
||||
|
||||
config = self.default_config()
|
||||
config["enable_registration"] = True
|
||||
config["cas_config"] = {
|
||||
"enabled": True,
|
||||
"server_url": "https://fake.test",
|
||||
"service_url": "https://matrix.goodserver.com:8448",
|
||||
}
|
||||
config["public_baseurl"] = self.base_url
|
||||
|
||||
async def get_raw(uri, args):
|
||||
"""Return an example response payload from a call to the `/proxyValidate`
|
||||
endpoint of a CAS server, copied from
|
||||
https://apereo.github.io/cas/5.0.x/protocol/CAS-Protocol-V2-Specification.html#26-proxyvalidate-cas-20
|
||||
|
||||
This needs to be returned by an async function (as opposed to set as the
|
||||
mock's return value) because the corresponding Synapse code awaits on it.
|
||||
"""
|
||||
return """
|
||||
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>username</cas:user>
|
||||
<cas:proxyGrantingTicket>PGTIOU-84678-8a9d...</cas:proxyGrantingTicket>
|
||||
<cas:proxies>
|
||||
<cas:proxy>https://proxy2/pgtUrl</cas:proxy>
|
||||
<cas:proxy>https://proxy1/pgtUrl</cas:proxy>
|
||||
</cas:proxies>
|
||||
</cas:authenticationSuccess>
|
||||
</cas:serviceResponse>
|
||||
"""
|
||||
|
||||
mocked_http_client = Mock(spec=["get_raw"])
|
||||
mocked_http_client.get_raw.side_effect = get_raw
|
||||
|
||||
self.hs = self.setup_test_homeserver(
|
||||
config=config, proxied_http_client=mocked_http_client,
|
||||
)
|
||||
|
||||
return self.hs
|
||||
|
||||
def test_cas_redirect_confirm(self):
|
||||
"""Tests that the SSO login flow serves a confirmation page before redirecting a
|
||||
user to the redirect URL.
|
||||
"""
|
||||
base_url = "/login/cas/ticket?redirectUrl"
|
||||
redirect_url = "https://dodgy-site.com/"
|
||||
|
||||
url_parts = list(urllib.parse.urlparse(base_url))
|
||||
query = dict(urllib.parse.parse_qsl(url_parts[4]))
|
||||
query.update({"redirectUrl": redirect_url})
|
||||
query.update({"ticket": "ticket"})
|
||||
url_parts[4] = urllib.parse.urlencode(query)
|
||||
cas_ticket_url = urllib.parse.urlunparse(url_parts)
|
||||
|
||||
# Get Synapse to call the fake CAS and serve the template.
|
||||
request, channel = self.make_request("GET", cas_ticket_url)
|
||||
self.render(request)
|
||||
|
||||
# Test that the response is HTML.
|
||||
content_type_header_value = ""
|
||||
for header in channel.result.get("headers", []):
|
||||
if header[0] == b"Content-Type":
|
||||
content_type_header_value = header[1].decode("utf8")
|
||||
|
||||
self.assertTrue(content_type_header_value.startswith("text/html"))
|
||||
|
||||
# Test that the body isn't empty.
|
||||
self.assertTrue(len(channel.result["body"]) > 0)
|
||||
|
||||
# And that it contains our redirect link
|
||||
self.assertIn(redirect_url, channel.result["body"].decode("UTF-8"))
|
||||
|
|
Loading…
Reference in New Issue