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

Support CAS in UI Auth flows. #7186

Merged
merged 9 commits into from
Apr 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
clokep marked this conversation as resolved.
Show resolved Hide resolved
"/_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.
clokep marked this conversation as resolved.
Show resolved Hide resolved
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.
clokep marked this conversation as resolved.
Show resolved Hide resolved

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