Skip to content

Commit

Permalink
Support CAS in UI Auth flows. (matrix-org#7186)
Browse files Browse the repository at this point in the history
  • Loading branch information
clokep authored and phil-flex committed Jun 16, 2020
1 parent 553ac04 commit b12dcdf
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 83 deletions.
1 change: 1 addition & 0 deletions changelog.d/7186.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support SSO in the user interactive authentication workflow.
4 changes: 2 additions & 2 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +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
self._sso_enabled = hs.config.saml2_enabled or 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 @@ -136,7 +136,7 @@ def __init__(self, hs):
# 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:
if self._sso_enabled:
ui_auth_types.append(LoginType.SSO)
self._supported_ui_auth_types = ui_auth_types

Expand Down
161 changes: 89 additions & 72 deletions synapse/handlers/cas_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import logging
import xml.etree.ElementTree as ET
from typing import AnyStr, Dict, Optional, Tuple
from typing import Dict, Optional, Tuple

from six.moves import urllib

Expand Down Expand Up @@ -48,26 +48,47 @@ def __init__(self, hs):

self._http_client = hs.get_proxied_http_client()

def _build_service_param(self, client_redirect_url: AnyStr) -> str:
def _build_service_param(self, args: Dict[str, str]) -> str:
"""
Generates a value to use as the "service" parameter when redirecting or
querying the CAS service.
Args:
args: Additional arguments to include in the final redirect URL.
Returns:
The URL to use as a "service" parameter.
"""
return "%s%s?%s" % (
self._cas_service_url,
"/_matrix/client/r0/login/cas/ticket",
urllib.parse.urlencode({"redirectUrl": client_redirect_url}),
urllib.parse.urlencode(args),
)

async def _handle_cas_response(
self, request: SynapseRequest, cas_response_body: str, client_redirect_url: str
) -> None:
async def _validate_ticket(
self, ticket: str, service_args: Dict[str, str]
) -> Tuple[str, Optional[str]]:
"""
Retrieves the user and display name from the CAS response and continues with the authentication.
Validate a CAS ticket with the server, parse the response, and return the user and display name.
Args:
request: The original client request.
cas_response_body: The response from the CAS server.
client_redirect_url: The URl to redirect the client to when
everything is done.
ticket: The CAS ticket from the client.
service_args: Additional arguments to include in the service URL.
Should be the same as those passed to `get_redirect_url`.
"""
user, attributes = self._parse_cas_response(cas_response_body)
uri = self._cas_server_url + "/proxyValidate"
args = {
"ticket": ticket,
"service": self._build_service_param(service_args),
}
try:
body = await self._http_client.get_raw(uri, args)
except PartialDownloadError as pde:
# Twisted raises this error if the connection is closed,
# even if that's being used old-http style to signal end-of-data
body = pde.response

user, attributes = self._parse_cas_response(body)
displayname = attributes.pop(self._cas_displayname_attribute, None)

for required_attribute, required_value in self._cas_required_attributes.items():
Expand All @@ -82,7 +103,7 @@ async def _handle_cas_response(
if required_value != actual_value:
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)

await self._on_successful_auth(user, request, client_redirect_url, displayname)
return user, displayname

def _parse_cas_response(
self, cas_response_body: str
Expand Down Expand Up @@ -127,78 +148,74 @@ def _parse_cas_response(
)
return user, attributes

async def _on_successful_auth(
self,
username: str,
request: SynapseRequest,
client_redirect_url: str,
user_display_name: Optional[str] = None,
) -> None:
"""Called once the user has successfully authenticated with the SSO.
Registers the user if necessary, and then returns a redirect (with
a login token) to the client.
def get_redirect_url(self, service_args: Dict[str, str]) -> str:
"""
Generates a URL for the CAS server where the client should be redirected.
Args:
username: the remote user id. We'll map this onto
something sane for a MXID localpath.
service_args: Additional arguments to include in the final redirect URL.
request: the incoming request from the browser. We'll
respond to it with a redirect.
Returns:
The URL to redirect the client to.
"""
args = urllib.parse.urlencode(
{"service": self._build_service_param(service_args)}
)

client_redirect_url: the redirect_url the client gave us when
it first started the process.
return "%s/login?%s" % (self._cas_server_url, args)

user_display_name: if set, and we have to register a new user,
we will set their displayname to this.
async def handle_ticket(
self,
request: SynapseRequest,
ticket: str,
client_redirect_url: Optional[str],
session: Optional[str],
) -> None:
"""
localpart = map_username_to_mxid_localpart(username)
user_id = UserID(localpart, self._hostname).to_string()
registered_user_id = await self._auth_handler.check_user_exists(user_id)
if not registered_user_id:
registered_user_id = await self._registration_handler.register_user(
localpart=localpart, default_display_name=user_display_name
)
Called once the user has successfully authenticated with the SSO.
Validates a CAS ticket sent by the client and completes the auth process.
self._auth_handler.complete_sso_login(
registered_user_id, request, client_redirect_url
)
If the user interactive authentication session is provided, marks the
UI Auth session as complete, then returns an HTML page notifying the
user they are done.
def handle_redirect_request(self, client_redirect_url: bytes) -> bytes:
"""
Generates a URL to the CAS server where the client should be redirected.
Otherwise, this registers the user if necessary, and then returns a
redirect (with a login token) to the client.
Args:
client_redirect_url: The final URL the client should go to after the
user has negotiated SSO.
request: the incoming request from the browser. We'll
respond to it with a redirect or an HTML page.
Returns:
The URL to redirect to.
"""
args = urllib.parse.urlencode(
{"service": self._build_service_param(client_redirect_url)}
)
ticket: The CAS ticket provided by the client.
return ("%s/login?%s" % (self._cas_server_url, args)).encode("ascii")
client_redirect_url: the redirectUrl parameter from the `/cas/ticket` HTTP request, if given.
This should be the same as the redirectUrl from the original `/login/sso/redirect` request.
async def handle_ticket_request(
self, request: SynapseRequest, client_redirect_url: str, ticket: str
) -> None:
session: The session parameter from the `/cas/ticket` HTTP request, if given.
This should be the UI Auth session id.
"""
Validates a CAS ticket sent by the client for login/registration.
args = {}
if client_redirect_url:
args["redirectUrl"] = client_redirect_url
if session:
args["session"] = session
username, user_display_name = await self._validate_ticket(ticket, args)

On a successful request, writes a redirect to the request.
"""
uri = self._cas_server_url + "/proxyValidate"
args = {
"ticket": ticket,
"service": self._build_service_param(client_redirect_url),
}
try:
body = await self._http_client.get_raw(uri, args)
except PartialDownloadError as pde:
# Twisted raises this error if the connection is closed,
# even if that's being used old-http style to signal end-of-data
body = pde.response
localpart = map_username_to_mxid_localpart(username)
user_id = UserID(localpart, self._hostname).to_string()
registered_user_id = await self._auth_handler.check_user_exists(user_id)

await self._handle_cas_response(request, body, client_redirect_url)
if session:
self._auth_handler.complete_sso_ui_auth(
registered_user_id, session, request,
)

else:
if not registered_user_id:
registered_user_id = await self._registration_handler.register_user(
localpart=localpart, default_display_name=user_display_name
)

self._auth_handler.complete_sso_login(
registered_user_id, request, client_redirect_url
)
20 changes: 16 additions & 4 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,9 @@ def __init__(self, hs):
self._cas_handler = hs.get_cas_handler()

def get_sso_url(self, client_redirect_url: bytes) -> bytes:
return self._cas_handler.handle_redirect_request(client_redirect_url)
return self._cas_handler.get_redirect_url(
{"redirectUrl": client_redirect_url}
).encode("ascii")


class CasTicketServlet(RestServlet):
Expand All @@ -436,10 +438,20 @@ def __init__(self, hs):
self._cas_handler = hs.get_cas_handler()

async def on_GET(self, request: SynapseRequest) -> None:
client_redirect_url = parse_string(request, "redirectUrl", required=True)
client_redirect_url = parse_string(request, "redirectUrl")
ticket = parse_string(request, "ticket", required=True)
await self._cas_handler.handle_ticket_request(
request, client_redirect_url, ticket

# Maybe get a session ID (if this ticket is from user interactive
# authentication).
session = parse_string(request, "session")

# Either client_redirect_url or session must be provided.
if not client_redirect_url and not session:
message = "Missing string query parameter redirectUrl or session"
raise SynapseError(400, message, errcode=Codes.MISSING_PARAM)

await self._cas_handler.handle_ticket(
request, ticket, client_redirect_url, session
)


Expand Down
28 changes: 23 additions & 5 deletions synapse/rest/client/v2_alpha/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ def __init__(self, hs):
self._saml_enabled = hs.config.saml2_enabled
if self._saml_enabled:
self._saml_handler = hs.get_saml_handler()
self._cas_enabled = hs.config.cas_enabled
if self._cas_enabled:
self._cas_handler = hs.get_cas_handler()
self._cas_server_url = hs.config.cas_server_url
self._cas_service_url = hs.config.cas_service_url

def on_GET(self, request, stagetype):
session = parse_string(request, "session")
Expand All @@ -133,14 +138,27 @@ def on_GET(self, request, stagetype):
% (CLIENT_API_PREFIX, LoginType.TERMS),
}

elif stagetype == LoginType.SSO and self._saml_enabled:
elif stagetype == LoginType.SSO:
# 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
)
if self._cas_enabled:
# Generate a request to CAS that redirects back to an endpoint
# to verify the successful authentication.
sso_redirect_url = self._cas_handler.get_redirect_url(
{"session": session},
)

elif self._saml_enabled:
client_redirect_url = ""
sso_redirect_url = self._saml_handler.handle_redirect_request(
client_redirect_url, session
)

else:
raise SynapseError(400, "Homeserver not configured for SSO.")

html = self.auth_handler.start_sso_ui_auth(sso_redirect_url, session)

else:
raise SynapseError(404, "Unknown auth stage type")

Expand Down

0 comments on commit b12dcdf

Please sign in to comment.