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

Support SAML in the user interactive authentication workflow. #7102

Merged
merged 10 commits into from
Apr 1, 2020
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Next version
============

* A new template (`sso_auth_confirm.html`) was added to Synapse. If your Synapse
is configured to use SSO and a custom `sso_redirect_confirm_template_dir`
configuration then this template will need to be duplicated into that
directory.

Synapse 1.12.0 (2020-03-23)
===========================

Expand Down
1 change: 1 addition & 0 deletions changelog.d/7102.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support SSO in the user interactive authentication workflow.
1 change: 1 addition & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class LoginType(object):
MSISDN = "m.login.msisdn"
RECAPTCHA = "m.login.recaptcha"
TERMS = "m.login.terms"
SSO = "org.matrix.login.sso"
DUMMY = "m.login.dummy"

# Only for C/S API v1
Expand Down
90 changes: 87 additions & 3 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@
logger = logging.getLogger(__name__)


SUCCESS_TEMPLATE = """
<html>
<head>
<title>Success!</title>
<meta name='viewport' content='width=device-width, initial-scale=1,
user-scalable=no, minimum-scale=1.0, maximum-scale=1.0'>
<link rel="stylesheet" href="/_matrix/static/client/register/style.css">
<script>
if (window.onAuthDone) {
window.onAuthDone();
} else if (window.opener && window.opener.postMessage) {
window.opener.postMessage("authDone", "*");
}
</script>
</head>
<body>
<div>
<p>Thank you</p>
<p>You may now close this window and return to the application</p>
</div>
</body>
</html>
"""


class AuthHandler(BaseHandler):
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000

Expand Down Expand Up @@ -91,6 +116,7 @@ def __init__(self, hs):
self.hs = hs # FIXME better possibility to access registrationHandler later?
self.macaroon_gen = hs.get_macaroon_generator()
self._password_enabled = hs.config.password_enabled
self._saml2_enabled = hs.config.saml2_enabled

# we keep this as a list despite the O(N^2) implication so that we can
# keep PASSWORD first and avoid confusing clients which pick the first
Expand All @@ -106,17 +132,35 @@ def __init__(self, hs):
if t not in login_types:
login_types.append(t)
self._supported_login_types = login_types
# Login types and UI Auth types have a heavy overlap, but are not
# necessarily identical. Login types have SSO (and other login types)
# added in the rest layer, see synapse.rest.client.v1.login.LoginRestServerlet.on_GET.
ui_auth_types = login_types.copy()
if self._saml2_enabled:
ui_auth_types.append(LoginType.SSO)
self._supported_ui_auth_types = ui_auth_types

# Ratelimiter for failed auth during UIA. Uses same ratelimit config
# as per `rc_login.failed_attempts`.
self._failed_uia_attempts_ratelimiter = Ratelimiter()

self._clock = self.hs.get_clock()

# Load the SSO redirect confirmation page HTML template
# Load the SSO HTML templates.
clokep marked this conversation as resolved.
Show resolved Hide resolved

# The following template is shown to the user before redirecting them to
# the SSO login page. It notifies the user they are about to be
# redirected and that the Synapse server will be gaining access to their
# SSO account.
clokep marked this conversation as resolved.
Show resolved Hide resolved
self._sso_redirect_confirm_template = load_jinja2_templates(
hs.config.sso_redirect_confirm_template_dir, ["sso_redirect_confirm.html"],
)[0]
# The following template is shown during user interactive authentication
# in the fallback auth scenario. It notifies the user that they are
# authenticating for an operation to occur on their account.
self._sso_auth_confirm_template = load_jinja2_templates(
clokep marked this conversation as resolved.
Show resolved Hide resolved
hs.config.sso_redirect_confirm_template_dir, ["sso_auth_confirm.html"],
)[0]

self._server_name = hs.config.server_name

Expand Down Expand Up @@ -175,7 +219,7 @@ def validate_user_via_ui_auth(
)

# build a list of supported flows
flows = [[login_type] for login_type in self._supported_login_types]
flows = [[login_type] for login_type in self._supported_ui_auth_types]

try:
result, params, _ = yield self.check_auth(
Expand All @@ -193,7 +237,7 @@ def validate_user_via_ui_auth(
raise

# find the completed login type
for login_type in self._supported_login_types:
for login_type in self._supported_ui_auth_types:
if login_type not in result:
continue

Expand Down Expand Up @@ -991,6 +1035,46 @@ def _do_validate_hash():
else:
return defer.succeed(False)

def start_sso_ui_auth(self, redirect_url: str) -> str:
"""
Get the HTML for the SSO redirect confirmation page.

:param redirect_url: The URL to redirect to the SSO provider.
:return: The HTML to render.
"""
return self._sso_auth_confirm_template.render(redirect_url=redirect_url,)

def complete_sso_ui_auth(
self, registered_user_id: str, session_id: str, request: SynapseRequest,
):
"""Having figured out a mxid for this user, complete the HTTP request

Args:
registered_user_id: The registered user ID to complete SSO login for.
request: The request to complete.
client_redirect_url: The URL to which to redirect the user at the end of the
process.
"""
# Mark the stage of the authentication as successful.
sess = self._get_session_info(session_id)
if "creds" not in sess:
sess["creds"] = {}
creds = sess["creds"]

# Save the user who authenticated with SSO, this will be used to ensure
# that the account be modified is also the person who logged in.
creds[LoginType.SSO] = registered_user_id
self._save_session(sess)

# Render the HTML and return.
html_bytes = SUCCESS_TEMPLATE.encode("utf8")
request.setResponseCode(200)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))

request.write(html_bytes)
finish_request(request)

def complete_sso_login(
self,
registered_user_id: str,
Expand Down
51 changes: 41 additions & 10 deletions synapse/handlers/saml_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# limitations under the License.
import logging
import re
from typing import Tuple
from typing import Optional, Tuple

import attr
import saml2
Expand Down Expand Up @@ -44,11 +44,15 @@ class Saml2SessionData:

# time the session was created, in milliseconds
creation_time = attr.ib()
# The user interactive authentication session ID associated with this SAML
# session (or None if this SAML session is for an initial login).
ui_auth_session_id = attr.ib(type=Optional[str], default=None)


class SamlHandler:
def __init__(self, hs):
self._saml_client = Saml2Client(hs.config.saml2_sp_config)
self._auth = hs.get_auth()
self._auth_handler = hs.get_auth_handler()
self._registration_handler = hs.get_registration_handler()

Expand Down Expand Up @@ -77,12 +81,14 @@ def __init__(self, hs):

self._error_html_content = hs.config.saml2_error_html_content

def handle_redirect_request(self, client_redirect_url):
def handle_redirect_request(self, client_redirect_url, ui_auth_session_id=None):
"""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
ui_auth_session_id (Optional[str]): The session ID of the ongoing UI Auth (or
None if this is a login).

Returns:
bytes: URL to redirect to
Expand All @@ -92,7 +98,9 @@ def handle_redirect_request(self, client_redirect_url):
)

now = self._clock.time_msec()
self._outstanding_requests_dict[reqid] = Saml2SessionData(creation_time=now)
self._outstanding_requests_dict[reqid] = Saml2SessionData(
creation_time=now, ui_auth_session_id=ui_auth_session_id,
)

for key, value in info["headers"]:
if key == "Location":
Expand All @@ -119,7 +127,9 @@ async def handle_saml_response(self, request):
self.expire_sessions()

try:
user_id = await self._map_saml_response_to_user(resp_bytes, relay_state)
user_id, current_session = await self._map_saml_response_to_user(
resp_bytes, relay_state
)
except RedirectException:
# Raise the exception as per the wishes of the SAML module response
raise
Expand All @@ -137,9 +147,28 @@ async def handle_saml_response(self, request):
finish_request(request)
return

self._auth_handler.complete_sso_login(user_id, request, relay_state)
# Complete the interactive auth session or the login.
if current_session and current_session.ui_auth_session_id:
self._auth_handler.complete_sso_ui_auth(
user_id, current_session.ui_auth_session_id, request
)

else:
self._auth_handler.complete_sso_login(user_id, request, relay_state)

async def _map_saml_response_to_user(
self, resp_bytes: str, client_redirect_url: str
) -> Tuple[str, Optional[Saml2SessionData]]:
"""
Given a sample response, retrieve the cached session and user for it.

async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
Args:
resp_bytes: The SAML response.
client_redirect_url: The redirect URL passed in by the client.

Returns:
Tuple of the user ID and SAML session associated with this response.
"""
try:
saml2_auth = self._saml_client.parse_authn_request_response(
resp_bytes,
Expand Down Expand Up @@ -167,7 +196,9 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):

logger.info("SAML2 mapped attributes: %s", saml2_auth.ava)

self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)
current_session = self._outstanding_requests_dict.pop(
saml2_auth.in_response_to, None
)

remote_user_id = self._user_mapping_provider.get_remote_user_id(
saml2_auth, client_redirect_url
Expand All @@ -188,7 +219,7 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
)
if registered_user_id is not None:
logger.info("Found existing mapping %s", registered_user_id)
return registered_user_id
return registered_user_id, current_session

# backwards-compatibility hack: see if there is an existing user with a
# suitable mapping from the uid
Expand All @@ -213,7 +244,7 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
await self._datastore.record_user_external_id(
self._auth_provider_id, remote_user_id, registered_user_id
)
return registered_user_id
return registered_user_id, current_session

# Map saml response to user attributes using the configured mapping provider
for i in range(1000):
Expand Down Expand Up @@ -260,7 +291,7 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
await self._datastore.record_user_external_id(
self._auth_provider_id, remote_user_id, registered_user_id
)
return registered_user_id
return registered_user_id, current_session

def expire_sessions(self):
expire_before = self._clock.time_msec() - self._saml2_session_lifetime
Expand Down
15 changes: 15 additions & 0 deletions synapse/res/templates/sso_auth_confirm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<html>
<head>
<title>Authentication</title>
</head>
<body>
<div>
<p>
A client is trying to remove a device/add an email address/take over
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming that you know this text needs changing!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but thank you for marking it! 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any thoughts on how customizable we want this to be? Do we want the "reason" to be templated or static?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I understand the question. The "reason" is defined by the operation the client is requesting, so it needs to be substituted somehow?

It might be worth seeking some feedback in the "Write Club" room (#write-club-internal:matrix.org) for suggestions on what this page ought to look like.

your account. To confirm this action,
<a href="{{ redirect_url | e }}">re-authenticate with single sign-on</a>.
If you did not expect this, your account may be compromised!
</p>
</div>
</body>
</html>
42 changes: 18 additions & 24 deletions synapse/rest/client/v2_alpha/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from synapse.api.constants import LoginType
from synapse.api.errors import SynapseError
from synapse.api.urls import CLIENT_API_PREFIX
from synapse.handlers.auth import SUCCESS_TEMPLATE
from synapse.http.server import finish_request
from synapse.http.servlet import RestServlet, parse_string

Expand Down Expand Up @@ -89,30 +90,6 @@
</html>
"""

SUCCESS_TEMPLATE = """
<html>
<head>
<title>Success!</title>
<meta name='viewport' content='width=device-width, initial-scale=1,
user-scalable=no, minimum-scale=1.0, maximum-scale=1.0'>
<link rel="stylesheet" href="/_matrix/static/client/register/style.css">
<script>
if (window.onAuthDone) {
window.onAuthDone();
} else if (window.opener && window.opener.postMessage) {
window.opener.postMessage("authDone", "*");
}
</script>
</head>
<body>
<div>
<p>Thank you</p>
<p>You may now close this window and return to the application</p>
</div>
</body>
</html>
"""


class AuthRestServlet(RestServlet):
"""
Expand All @@ -130,6 +107,11 @@ def __init__(self, hs):
self.auth_handler = hs.get_auth_handler()
self.registration_handler = hs.get_registration_handler()

# SSO configuration.
self._saml_enabled = hs.config.saml2_enabled
if self._saml_enabled:
self._saml_handler = hs.get_saml_handler()

def on_GET(self, request, stagetype):
session = parse_string(request, "session")
if not session:
Expand All @@ -150,6 +132,15 @@ def on_GET(self, request, stagetype):
"myurl": "%s/r0/auth/%s/fallback/web"
% (CLIENT_API_PREFIX, LoginType.TERMS),
}

elif stagetype == LoginType.SSO and self._saml_enabled:
# Display a confirmation page which prompts the user to
# re-authenticate with their SSO provider.
client_redirect_url = ""
sso_redirect_url = self._saml_handler.handle_redirect_request(
client_redirect_url, session
)
html = self.auth_handler.start_sso_ui_auth(sso_redirect_url)
else:
raise SynapseError(404, "Unknown auth stage type")

Expand Down Expand Up @@ -210,6 +201,9 @@ async def on_POST(self, request, stagetype):
"myurl": "%s/r0/auth/%s/fallback/web"
% (CLIENT_API_PREFIX, LoginType.TERMS),
}
elif stagetype == LoginType.SSO:
# The SSO fallback workflow should not post here,
raise SynapseError(404, "Fallback SSO auth does not support POST requests.")
else:
raise SynapseError(404, "Unknown auth stage type")

Expand Down