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

Commit b9930d2

Browse files
authored
Support SAML in the user interactive authentication workflow. (#7102)
1 parent 468dcc7 commit b9930d2

File tree

11 files changed

+227
-44
lines changed

11 files changed

+227
-44
lines changed

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
Next version
2+
============
3+
4+
* A new template (`sso_auth_confirm.html`) was added to Synapse. If your Synapse
5+
is configured to use SSO and a custom `sso_redirect_confirm_template_dir`
6+
configuration then this template will need to be duplicated into that
7+
directory.
8+
19
Synapse 1.12.0 (2020-03-23)
210
===========================
311

changelog.d/7102.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support SSO in the user interactive authentication workflow.

synapse/api/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class LoginType(object):
6161
MSISDN = "m.login.msisdn"
6262
RECAPTCHA = "m.login.recaptcha"
6363
TERMS = "m.login.terms"
64+
SSO = "org.matrix.login.sso"
6465
DUMMY = "m.login.dummy"
6566

6667
# Only for C/S API v1

synapse/handlers/auth.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@
5353
logger = logging.getLogger(__name__)
5454

5555

56+
SUCCESS_TEMPLATE = """
57+
<html>
58+
<head>
59+
<title>Success!</title>
60+
<meta name='viewport' content='width=device-width, initial-scale=1,
61+
user-scalable=no, minimum-scale=1.0, maximum-scale=1.0'>
62+
<link rel="stylesheet" href="/_matrix/static/client/register/style.css">
63+
<script>
64+
if (window.onAuthDone) {
65+
window.onAuthDone();
66+
} else if (window.opener && window.opener.postMessage) {
67+
window.opener.postMessage("authDone", "*");
68+
}
69+
</script>
70+
</head>
71+
<body>
72+
<div>
73+
<p>Thank you</p>
74+
<p>You may now close this window and return to the application</p>
75+
</div>
76+
</body>
77+
</html>
78+
"""
79+
80+
5681
class AuthHandler(BaseHandler):
5782
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000
5883

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

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

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

114147
self._clock = self.hs.get_clock()
115148

116-
# Load the SSO redirect confirmation page HTML template
149+
# Load the SSO HTML templates.
150+
151+
# The following template is shown to the user during a client login via SSO,
152+
# after the SSO completes and before redirecting them back to their client.
153+
# It notifies the user they are about to give access to their matrix account
154+
# to the client.
117155
self._sso_redirect_confirm_template = load_jinja2_templates(
118156
hs.config.sso_redirect_confirm_template_dir, ["sso_redirect_confirm.html"],
119157
)[0]
158+
# The following template is shown during user interactive authentication
159+
# in the fallback auth scenario. It notifies the user that they are
160+
# authenticating for an operation to occur on their account.
161+
self._sso_auth_confirm_template = load_jinja2_templates(
162+
hs.config.sso_redirect_confirm_template_dir, ["sso_auth_confirm.html"],
163+
)[0]
120164

121165
self._server_name = hs.config.server_name
122166

@@ -130,6 +174,7 @@ def validate_user_via_ui_auth(
130174
request: SynapseRequest,
131175
request_body: Dict[str, Any],
132176
clientip: str,
177+
description: str,
133178
):
134179
"""
135180
Checks that the user is who they claim to be, via a UI auth.
@@ -147,6 +192,9 @@ def validate_user_via_ui_auth(
147192
148193
clientip: The IP address of the client.
149194
195+
description: A human readable string to be displayed to the user that
196+
describes the operation happening on their account.
197+
150198
Returns:
151199
defer.Deferred[dict]: the parameters for this request (which may
152200
have been given only in a previous call).
@@ -175,11 +223,11 @@ def validate_user_via_ui_auth(
175223
)
176224

177225
# build a list of supported flows
178-
flows = [[login_type] for login_type in self._supported_login_types]
226+
flows = [[login_type] for login_type in self._supported_ui_auth_types]
179227

180228
try:
181229
result, params, _ = yield self.check_auth(
182-
flows, request, request_body, clientip
230+
flows, request, request_body, clientip, description
183231
)
184232
except LoginError:
185233
# Update the ratelimite to say we failed (`can_do_action` doesn't raise).
@@ -193,7 +241,7 @@ def validate_user_via_ui_auth(
193241
raise
194242

195243
# find the completed login type
196-
for login_type in self._supported_login_types:
244+
for login_type in self._supported_ui_auth_types:
197245
if login_type not in result:
198246
continue
199247

@@ -224,6 +272,7 @@ def check_auth(
224272
request: SynapseRequest,
225273
clientdict: Dict[str, Any],
226274
clientip: str,
275+
description: str,
227276
):
228277
"""
229278
Takes a dictionary sent by the client in the login / registration
@@ -250,6 +299,9 @@ def check_auth(
250299
251300
clientip: The IP address of the client.
252301
302+
description: A human readable string to be displayed to the user that
303+
describes the operation happening on their account.
304+
253305
Returns:
254306
defer.Deferred[dict, dict, str]: a deferred tuple of
255307
(creds, params, session_id).
@@ -299,12 +351,18 @@ def check_auth(
299351
comparator = (request.uri, request.method, clientdict)
300352
if "ui_auth" not in session:
301353
session["ui_auth"] = comparator
354+
self._save_session(session)
302355
elif session["ui_auth"] != comparator:
303356
raise SynapseError(
304357
403,
305358
"Requested operation has changed during the UI authentication session.",
306359
)
307360

361+
# Add a human readable description to the session.
362+
if "description" not in session:
363+
session["description"] = description
364+
self._save_session(session)
365+
308366
if not authdict:
309367
raise InteractiveAuthIncompleteError(
310368
self._auth_dict_for_flows(flows, session)
@@ -991,6 +1049,56 @@ def _do_validate_hash():
9911049
else:
9921050
return defer.succeed(False)
9931051

1052+
def start_sso_ui_auth(self, redirect_url: str, session_id: str) -> str:
1053+
"""
1054+
Get the HTML for the SSO redirect confirmation page.
1055+
1056+
Args:
1057+
redirect_url: The URL to redirect to the SSO provider.
1058+
session_id: The user interactive authentication session ID.
1059+
1060+
Returns:
1061+
The HTML to render.
1062+
"""
1063+
session = self._get_session_info(session_id)
1064+
# Get the human readable operation of what is occurring, falling back to
1065+
# a generic message if it isn't available for some reason.
1066+
description = session.get("description", "modify your account")
1067+
return self._sso_auth_confirm_template.render(
1068+
description=description, redirect_url=redirect_url,
1069+
)
1070+
1071+
def complete_sso_ui_auth(
1072+
self, registered_user_id: str, session_id: str, request: SynapseRequest,
1073+
):
1074+
"""Having figured out a mxid for this user, complete the HTTP request
1075+
1076+
Args:
1077+
registered_user_id: The registered user ID to complete SSO login for.
1078+
request: The request to complete.
1079+
client_redirect_url: The URL to which to redirect the user at the end of the
1080+
process.
1081+
"""
1082+
# Mark the stage of the authentication as successful.
1083+
sess = self._get_session_info(session_id)
1084+
if "creds" not in sess:
1085+
sess["creds"] = {}
1086+
creds = sess["creds"]
1087+
1088+
# Save the user who authenticated with SSO, this will be used to ensure
1089+
# that the account be modified is also the person who logged in.
1090+
creds[LoginType.SSO] = registered_user_id
1091+
self._save_session(sess)
1092+
1093+
# Render the HTML and return.
1094+
html_bytes = SUCCESS_TEMPLATE.encode("utf8")
1095+
request.setResponseCode(200)
1096+
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
1097+
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
1098+
1099+
request.write(html_bytes)
1100+
finish_request(request)
1101+
9941102
def complete_sso_login(
9951103
self,
9961104
registered_user_id: str,

synapse/handlers/saml_handler.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# limitations under the License.
1515
import logging
1616
import re
17-
from typing import Tuple
17+
from typing import Optional, Tuple
1818

1919
import attr
2020
import saml2
@@ -44,11 +44,15 @@ class Saml2SessionData:
4444

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

4851

4952
class SamlHandler:
5053
def __init__(self, hs):
5154
self._saml_client = Saml2Client(hs.config.saml2_sp_config)
55+
self._auth = hs.get_auth()
5256
self._auth_handler = hs.get_auth_handler()
5357
self._registration_handler = hs.get_registration_handler()
5458

@@ -77,12 +81,14 @@ def __init__(self, hs):
7781

7882
self._error_html_content = hs.config.saml2_error_html_content
7983

80-
def handle_redirect_request(self, client_redirect_url):
84+
def handle_redirect_request(self, client_redirect_url, ui_auth_session_id=None):
8185
"""Handle an incoming request to /login/sso/redirect
8286
8387
Args:
8488
client_redirect_url (bytes): the URL that we should redirect the
8589
client to when everything is done
90+
ui_auth_session_id (Optional[str]): The session ID of the ongoing UI Auth (or
91+
None if this is a login).
8692
8793
Returns:
8894
bytes: URL to redirect to
@@ -92,7 +98,9 @@ def handle_redirect_request(self, client_redirect_url):
9298
)
9399

94100
now = self._clock.time_msec()
95-
self._outstanding_requests_dict[reqid] = Saml2SessionData(creation_time=now)
101+
self._outstanding_requests_dict[reqid] = Saml2SessionData(
102+
creation_time=now, ui_auth_session_id=ui_auth_session_id,
103+
)
96104

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

121129
try:
122-
user_id = await self._map_saml_response_to_user(resp_bytes, relay_state)
130+
user_id, current_session = await self._map_saml_response_to_user(
131+
resp_bytes, relay_state
132+
)
123133
except RedirectException:
124134
# Raise the exception as per the wishes of the SAML module response
125135
raise
@@ -137,9 +147,28 @@ async def handle_saml_response(self, request):
137147
finish_request(request)
138148
return
139149

140-
self._auth_handler.complete_sso_login(user_id, request, relay_state)
150+
# Complete the interactive auth session or the login.
151+
if current_session and current_session.ui_auth_session_id:
152+
self._auth_handler.complete_sso_ui_auth(
153+
user_id, current_session.ui_auth_session_id, request
154+
)
155+
156+
else:
157+
self._auth_handler.complete_sso_login(user_id, request, relay_state)
158+
159+
async def _map_saml_response_to_user(
160+
self, resp_bytes: str, client_redirect_url: str
161+
) -> Tuple[str, Optional[Saml2SessionData]]:
162+
"""
163+
Given a sample response, retrieve the cached session and user for it.
141164
142-
async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
165+
Args:
166+
resp_bytes: The SAML response.
167+
client_redirect_url: The redirect URL passed in by the client.
168+
169+
Returns:
170+
Tuple of the user ID and SAML session associated with this response.
171+
"""
143172
try:
144173
saml2_auth = self._saml_client.parse_authn_request_response(
145174
resp_bytes,
@@ -167,7 +196,9 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
167196

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

170-
self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)
199+
current_session = self._outstanding_requests_dict.pop(
200+
saml2_auth.in_response_to, None
201+
)
171202

172203
remote_user_id = self._user_mapping_provider.get_remote_user_id(
173204
saml2_auth, client_redirect_url
@@ -188,7 +219,7 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
188219
)
189220
if registered_user_id is not None:
190221
logger.info("Found existing mapping %s", registered_user_id)
191-
return registered_user_id
222+
return registered_user_id, current_session
192223

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

218249
# Map saml response to user attributes using the configured mapping provider
219250
for i in range(1000):
@@ -260,7 +291,7 @@ async def _map_saml_response_to_user(self, resp_bytes, client_redirect_url):
260291
await self._datastore.record_user_external_id(
261292
self._auth_provider_id, remote_user_id, registered_user_id
262293
)
263-
return registered_user_id
294+
return registered_user_id, current_session
264295

265296
def expire_sessions(self):
266297
expire_before = self._clock.time_msec() - self._saml2_session_lifetime
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<html>
2+
<head>
3+
<title>Authentication</title>
4+
</head>
5+
<body>
6+
<div>
7+
<p>
8+
A client is trying to {{ description | e }}. To confirm this action,
9+
<a href="{{ redirect_url | e }}">re-authenticate with single sign-on</a>.
10+
If you did not expect this, your account may be compromised!
11+
</p>
12+
</div>
13+
</body>
14+
</html>

0 commit comments

Comments
 (0)