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

Commit

Permalink
Support SSO in the user interactive authentication workflow.
Browse files Browse the repository at this point in the history
  • Loading branch information
clokep committed Mar 18, 2020
1 parent 4a17a64 commit c79b150
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 41 deletions.
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
40 changes: 40 additions & 0 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ 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
# TODO CAS?
# self._cas_enabled = hs.config.cas_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 @@ -105,6 +108,8 @@ def __init__(self, hs):
for t in provider.get_supported_login_types().keys():
if t not in login_types:
login_types.append(t)
if self._saml2_enabled:
login_types.append(LoginType.SSO)
self._supported_login_types = login_types

# Ratelimiter for failed auth during UIA. Uses same ratelimit config
Expand Down Expand Up @@ -962,6 +967,41 @@ def _do_validate_hash():
else:
return defer.succeed(False)

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"]

creds[LoginType.SSO] = True
self._save_session(sess)

# TODO Validate the user ID?
# TODO Rate limiting?

# TODO Import this seems wrong.
from synapse.rest.client.v2_alpha.auth import SUCCESS_TEMPLATE

# 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
36 changes: 28 additions & 8 deletions synapse/handlers/saml_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ 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(default=None)


class SamlHandler:
Expand Down Expand Up @@ -76,12 +79,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 (str): The session ID of the ongoing UI Auth (or
None if this is a login).
Returns:
bytes: URL to redirect to
Expand All @@ -91,7 +96,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 @@ -118,7 +125,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 Exception as e:
# If decoding the response or mapping it to a user failed, then log the
# error and tell the user that something went wrong.
Expand All @@ -133,7 +142,14 @@ 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, client_redirect_url):
try:
Expand Down Expand Up @@ -163,7 +179,11 @@ 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
)

# TODO Handle no request?

remote_user_id = self._user_mapping_provider.get_remote_user_id(
saml2_auth, client_redirect_url
Expand All @@ -184,7 +204,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 @@ -209,7 +229,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 @@ -256,7 +276,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
2 changes: 2 additions & 0 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,8 @@ def handle_cas_response(self, request, cas_response_body, client_redirect_url):
if required_value != actual_value:
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)

# TODO Do something here to complete the ui-auth instead of login.

return self._sso_auth_handler.on_successful_auth(
user, request, client_redirect_url, displayname
)
Expand Down
101 changes: 68 additions & 33 deletions synapse/rest/client/v2_alpha/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@
</html>
"""

SAML2_TEMPLATE = """
<html>
<head>
<title>Authentication</title>
</head>
<body>
<div>
<p>
A client is trying to remove a device/add an email address/take over
your account. To confirm this action,
<a href="%(myurl)s">re-authenticate with single sign-on</a>.
If you did not expect this, your account may be compromised!
</p>
</div>
</body>
</html>
"""


class AuthRestServlet(RestServlet):
"""
Expand All @@ -129,6 +147,7 @@ def __init__(self, hs):
self.auth = hs.get_auth()
self.auth_handler = hs.get_auth_handler()
self.registration_handler = hs.get_registration_handler()
self._saml_handler = hs.get_saml_handler()

def on_GET(self, request, stagetype):
session = parse_string(request, "session")
Expand All @@ -142,14 +161,6 @@ def on_GET(self, request, stagetype):
% (CLIENT_API_PREFIX, LoginType.RECAPTCHA),
"sitekey": self.hs.config.recaptcha_public_key,
}
html_bytes = html.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)
return None
elif stagetype == LoginType.TERMS:
html = TERMS_TEMPLATE % {
"session": session,
Expand All @@ -158,17 +169,29 @@ def on_GET(self, request, stagetype):
"myurl": "%s/r0/auth/%s/fallback/web"
% (CLIENT_API_PREFIX, LoginType.TERMS),
}
html_bytes = html.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)
return None
elif stagetype == LoginType.SSO:
# TODO Display a confirmation page which lets the user redirect to
# SAML / CAS.
client_redirect_url = ""
value = self._saml_handler.handle_redirect_request(
client_redirect_url, session
)
html = SAML2_TEMPLATE % {
"myurl": value,
}
else:
raise SynapseError(404, "Unknown auth stage type")

# Render the HTML and return.
html_bytes = html.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)
return None

async def on_POST(self, request, stagetype):

session = parse_string(request, "session")
Expand Down Expand Up @@ -196,15 +219,6 @@ async def on_POST(self, request, stagetype):
% (CLIENT_API_PREFIX, LoginType.RECAPTCHA),
"sitekey": self.hs.config.recaptcha_public_key,
}
html_bytes = html.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)

return None
elif stagetype == LoginType.TERMS:
authdict = {"session": session}

Expand All @@ -225,17 +239,38 @@ async def on_POST(self, request, stagetype):
"myurl": "%s/r0/auth/%s/fallback/web"
% (CLIENT_API_PREFIX, LoginType.TERMS),
}
html_bytes = html.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)
return None
elif stagetype == LoginType.SSO:
# TODO Display an error page here? Is the 404 below enough?
authdict = {"session": session}

# TODO
success = await self.auth_handler.add_oob_auth(
LoginType.TERMS, authdict, self.hs.get_ip_from_request(request)
)

if success:
html = SUCCESS_TEMPLATE
else:
client_redirect_url = ""
value = self._saml_handler.handle_redirect_request(
client_redirect_url, session
)
html = SAML2_TEMPLATE % {
"myurl": value,
}
else:
raise SynapseError(404, "Unknown auth stage type")

# Render the HTML and return.
html_bytes = html.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)
return None

def on_OPTIONS(self, _):
return 200, {}

Expand Down

0 comments on commit c79b150

Please sign in to comment.