Support CAS in UI Auth flows.

pull/7186/head
Patrick Cloke 2020-03-25 09:03:51 -04:00
parent b9930d24a0
commit b4329db27a
4 changed files with 143 additions and 72 deletions

View File

@ -116,7 +116,7 @@ class AuthHandler(BaseHandler):
self.hs = hs # FIXME better possibility to access registrationHandler later? self.hs = hs # FIXME better possibility to access registrationHandler later?
self.macaroon_gen = hs.get_macaroon_generator() self.macaroon_gen = hs.get_macaroon_generator()
self._password_enabled = hs.config.password_enabled 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 # 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 # keep PASSWORD first and avoid confusing clients which pick the first
@ -136,7 +136,7 @@ class AuthHandler(BaseHandler):
# necessarily identical. Login types have SSO (and other login types) # necessarily identical. Login types have SSO (and other login types)
# added in the rest layer, see synapse.rest.client.v1.login.LoginRestServerlet.on_GET. # added in the rest layer, see synapse.rest.client.v1.login.LoginRestServerlet.on_GET.
ui_auth_types = login_types.copy() ui_auth_types = login_types.copy()
if self._saml2_enabled: if self._sso_enabled:
ui_auth_types.append(LoginType.SSO) ui_auth_types.append(LoginType.SSO)
self._supported_ui_auth_types = ui_auth_types self._supported_ui_auth_types = ui_auth_types

View File

@ -15,7 +15,7 @@
import logging import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import AnyStr, Dict, Optional, Tuple from typing import Dict, Optional, Tuple
from six.moves import urllib from six.moves import urllib
@ -48,26 +48,53 @@ class CasHandler:
self._http_client = hs.get_proxied_http_client() self._http_client = hs.get_proxied_http_client()
def _build_service_param(self, client_redirect_url: AnyStr) -> str: def _build_service_param(self, service_redirect_endpoint: str, **kwargs) -> str:
return "%s%s?%s" % (
self._cas_service_url,
"/_matrix/client/r0/login/cas/ticket",
urllib.parse.urlencode({"redirectUrl": client_redirect_url}),
)
async def _handle_cas_response(
self, request: SynapseRequest, cas_response_body: str, client_redirect_url: str
) -> None:
""" """
Retrieves the user and display name from the CAS response and continues with the authentication. Generates a value to use as the "service" parameter when redirecting or
querying the CAS service.
Args: Args:
request: The original client request. service_redirect_endpoint: The homeserver endpoint to redirect
cas_response_body: The response from the CAS server. the client to after successful SSO negotiation.
client_redirect_url: The URl to redirect the client to when kwargs: Additional arguments to include in the final redirect URL.
everything is done.
Returns:
The URL to use as a "service" parameter.
""" """
user, attributes = self._parse_cas_response(cas_response_body) return "%s%s?%s" % (
self._cas_service_url,
service_redirect_endpoint,
urllib.parse.urlencode(kwargs),
)
async def _validate_ticket(
self, ticket: str, service_redirect_endpoint: str, client_redirect_url: str
) -> Tuple[str, Optional[str]]:
"""
Validate a CAS ticket with the server, parse the response, and return the user and display name.
Args:
ticket: The CAS ticket from the client.
service_redirect_endpoint: The homeserver endpoint that the client
accessed to validate the ticket.
client_redirect_url: The URL to redirect the client to after
validation is done.
"""
uri = self._cas_server_url + "/proxyValidate"
args = {
"ticket": ticket,
"service": self._build_service_param(
service_redirect_endpoint, redirectUrl=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
user, attributes = self._parse_cas_response(body)
displayname = attributes.pop(self._cas_displayname_attribute, None) displayname = attributes.pop(self._cas_displayname_attribute, None)
for required_attribute, required_value in self._cas_required_attributes.items(): for required_attribute, required_value in self._cas_required_attributes.items():
@ -82,7 +109,7 @@ class CasHandler:
if required_value != actual_value: if required_value != actual_value:
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED) 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( def _parse_cas_response(
self, cas_response_body: str self, cas_response_body: str
@ -127,31 +154,46 @@ class CasHandler:
) )
return user, attributes return user, attributes
async def _on_successful_auth( def get_redirect_url(self, service_redirect_endpoint: str, **kwargs) -> str:
self, """
username: str, Generates a URL to the CAS server where the client should be redirected.
request: SynapseRequest,
client_redirect_url: str, Args:
user_display_name: Optional[str] = None, service_redirect_endpoint: The homeserver endpoint to redirect
the client to after successful SSO negotiation.
kwargs: Additional arguments to include in the final redirect URL.
Returns:
The URL to redirect the client to.
"""
args = urllib.parse.urlencode(
{"service": self._build_service_param(service_redirect_endpoint, **kwargs)}
)
return "%s/login?%s" % (self._cas_server_url, args)
async def handle_login_request(
self, request: SynapseRequest, client_redirect_url: str, ticket: str
) -> None: ) -> None:
"""Called once the user has successfully authenticated with the SSO. """
Validates a CAS ticket sent by the client for login and authenticates the user with SSO.
Registers the user if necessary, and then returns a redirect (with Registers the user if necessary, and then returns a redirect (with
a login token) to the client. a login token) to the client.
Args: Args:
username: the remote user id. We'll map this onto
something sane for a MXID localpath.
request: the incoming request from the browser. We'll request: the incoming request from the browser. We'll
respond to it with a redirect. respond to it with a redirect.
client_redirect_url: the redirect_url the client gave us when client_redirect_url: the redirect_url the client gave us when
it first started the process. it first started the process.
user_display_name: if set, and we have to register a new user, ticket: The CAS ticket provided by the client.
we will set their displayname to this.
""" """
username, user_display_name = await self._validate_ticket(
ticket, "/_matrix/client/r0/login/cas/ticket", client_redirect_url
)
localpart = map_username_to_mxid_localpart(username) localpart = map_username_to_mxid_localpart(username)
user_id = UserID(localpart, self._hostname).to_string() user_id = UserID(localpart, self._hostname).to_string()
registered_user_id = await self._auth_handler.check_user_exists(user_id) registered_user_id = await self._auth_handler.check_user_exists(user_id)
@ -164,41 +206,31 @@ class CasHandler:
registered_user_id, request, client_redirect_url registered_user_id, request, client_redirect_url
) )
def handle_redirect_request(self, client_redirect_url: bytes) -> bytes: async def handle_ui_auth_response(
""" self, request: SynapseRequest, ticket: str, session_id: str
Generates a URL to the CAS server where the client should be redirected.
Args:
client_redirect_url: The final URL the client should go to after the
user has negotiated SSO.
Returns:
The URL to redirect to.
"""
args = urllib.parse.urlencode(
{"service": self._build_service_param(client_redirect_url)}
)
return ("%s/login?%s" % (self._cas_server_url, args)).encode("ascii")
async def handle_ticket_request(
self, request: SynapseRequest, client_redirect_url: str, ticket: str
) -> None: ) -> None:
""" """
Validates a CAS ticket sent by the client for login/registration. Validates a CAS ticket sent by the client for user interactive authentication.
On a successful request, writes a redirect to the request. If successful, this completes the SSO step of UI auth and returns a
an HTML page to the client.
Args:
request: the incoming request from the browser.
ticket: The CAS ticket provided by the client.
session_id: The UI Auth session ID.
""" """
uri = self._cas_server_url + "/proxyValidate" client_redirect_url = ""
args = { user, _ = await self._validate_ticket(
"ticket": ticket, ticket, "/_matrix/client/r0/auth/cas/ticket", client_redirect_url
"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
await self._handle_cas_response(request, body, client_redirect_url) localpart = map_username_to_mxid_localpart(user)
user_id = UserID(localpart, self._hostname).to_string()
registered_user_id = await self._auth_handler.check_user_exists(user_id)
self._auth_handler.complete_sso_ui_auth(
registered_user_id, session_id, request,
)

View File

@ -425,7 +425,9 @@ class CasRedirectServlet(BaseSSORedirectServlet):
self._cas_handler = hs.get_cas_handler() self._cas_handler = hs.get_cas_handler()
def get_sso_url(self, client_redirect_url: bytes) -> bytes: 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(
"/_matrix/client/r0/login/cas/ticket", redirectUrl=client_redirect_url
).encode("ascii")
class CasTicketServlet(RestServlet): class CasTicketServlet(RestServlet):
@ -438,7 +440,7 @@ class CasTicketServlet(RestServlet):
async def on_GET(self, request: SynapseRequest) -> None: async def on_GET(self, request: SynapseRequest) -> None:
client_redirect_url = parse_string(request, "redirectUrl", required=True) client_redirect_url = parse_string(request, "redirectUrl", required=True)
ticket = parse_string(request, "ticket", required=True) ticket = parse_string(request, "ticket", required=True)
await self._cas_handler.handle_ticket_request( await self._cas_handler.handle_login_request(
request, client_redirect_url, ticket request, client_redirect_url, ticket
) )

View File

@ -111,6 +111,11 @@ class AuthRestServlet(RestServlet):
self._saml_enabled = hs.config.saml2_enabled self._saml_enabled = hs.config.saml2_enabled
if self._saml_enabled: if self._saml_enabled:
self._saml_handler = hs.get_saml_handler() 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): def on_GET(self, request, stagetype):
session = parse_string(request, "session") session = parse_string(request, "session")
@ -133,14 +138,27 @@ class AuthRestServlet(RestServlet):
% (CLIENT_API_PREFIX, LoginType.TERMS), % (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 # Display a confirmation page which prompts the user to
# re-authenticate with their SSO provider. # re-authenticate with their SSO provider.
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(
"/_matrix/client/r0/auth/cas/ticket", session=session,
)
elif self._saml_enabled:
client_redirect_url = "" client_redirect_url = ""
sso_redirect_url = self._saml_handler.handle_redirect_request( sso_redirect_url = self._saml_handler.handle_redirect_request(
client_redirect_url, session 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) html = self.auth_handler.start_sso_ui_auth(sso_redirect_url, session)
else: else:
raise SynapseError(404, "Unknown auth stage type") raise SynapseError(404, "Unknown auth stage type")
@ -221,5 +239,24 @@ class AuthRestServlet(RestServlet):
return 200, {} return 200, {}
class CasAuthTicketServlet(RestServlet):
PATTERNS = client_patterns(r"/auth/cas/ticket")
def __init__(self, hs):
super(CasAuthTicketServlet, self).__init__()
self._cas_handler = hs.get_cas_handler()
async def on_GET(self, request):
ticket = parse_string(request, "ticket", required=True)
# Pull the UI Auth session ID out.
session_id = parse_string(request, "session", required=True)
return await self._cas_handler.handle_ui_auth_response(
request, ticket, session_id
)
def register_servlets(hs, http_server): def register_servlets(hs, http_server):
AuthRestServlet(hs).register(http_server) AuthRestServlet(hs).register(http_server)
if hs.config.cas_enabled:
CasAuthTicketServlet(hs).register(http_server)