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

Simplify the flow for SSO UIA #8881

Merged
merged 4 commits into from
Dec 8, 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/8881.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Simplify logic for handling user-interactive-auth via single-sign-on servers.
1 change: 1 addition & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ files =
synapse/handlers/room_member.py,
synapse/handlers/room_member_worker.py,
synapse/handlers/saml_handler.py,
synapse/handlers/sso.py,
synapse/handlers/sync.py,
synapse/handlers/ui_auth,
synapse/http/client.py,
Expand Down
4 changes: 4 additions & 0 deletions synapse/handlers/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
class BaseHandler:
"""
Common base class for the event handlers.

Deprecated: new code should not use this. Instead, Handler classes should define the
fields they actually need. The utility methods should either be factored out to
standalone helper functions, or to different Handler classes.
"""

def __init__(self, hs: "HomeServer"):
Expand Down
11 changes: 6 additions & 5 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import bcrypt
import pymacaroons

from twisted.web.http import Request

from synapse.api.constants import LoginType
from synapse.api.errors import (
AuthError,
Expand Down Expand Up @@ -1331,15 +1333,14 @@ async def start_sso_ui_auth(self, redirect_url: str, session_id: str) -> str:
)

async def complete_sso_ui_auth(
self, registered_user_id: str, session_id: str, request: SynapseRequest,
self, registered_user_id: str, session_id: str, request: Request,
):
"""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.
session_id: The ID of the user-interactive auth session.
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.
# Save the user who authenticated with SSO, this will be used to ensure
Expand All @@ -1355,7 +1356,7 @@ async def complete_sso_ui_auth(
async def complete_sso_login(
self,
registered_user_id: str,
request: SynapseRequest,
request: Request,
client_redirect_url: str,
extra_attributes: Optional[JsonDict] = None,
):
Expand Down Expand Up @@ -1383,7 +1384,7 @@ async def complete_sso_login(
def _complete_sso_login(
self,
registered_user_id: str,
request: SynapseRequest,
request: Request,
client_redirect_url: str,
extra_attributes: Optional[JsonDict] = None,
):
Expand Down
44 changes: 32 additions & 12 deletions synapse/handlers/oidc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,21 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
self._sso_handler.render_error(request, "invalid_token", str(e))
return

# first check if we're doing a UIA
if ui_auth_session_id:
try:
remote_user_id = self._remote_id_from_userinfo(userinfo)
except Exception as e:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this only be MappingException?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, I don't think so.

We used to catch all exceptions (and turn them into MappingExceptions) at https://github.com/matrix-org/synapse/pull/8881/files#diff-b70d930a35a860a3c89284899c7c71ead788407bb579dca9d2d2d82dd4cf321dR870-R873, so I'm just copying that. (In this case we can log the exception immediately, rather than turning it into a MappingException for a higher-level method to catch).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, makes sense. Just differs a bit from the SAML code is why I was asking. It seems fine. 👍

logger.exception("Could not extract remote user id")
self._sso_handler.render_error(request, "mapping_error", str(e))
return

return await self._sso_handler.complete_sso_ui_auth_request(
self._auth_provider_id, remote_user_id, ui_auth_session_id, request
)

# otherwise, it's a login

# Pull out the user-agent and IP from the request.
user_agent = request.get_user_agent("")
ip_address = self.hs.get_ip_from_request(request)
Expand All @@ -698,14 +713,9 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
extra_attributes = await get_extra_attributes(userinfo, token)

# and finally complete the login
if ui_auth_session_id:
await self._auth_handler.complete_sso_ui_auth(
user_id, ui_auth_session_id, request
)
else:
await self._auth_handler.complete_sso_login(
user_id, request, client_redirect_url, extra_attributes
)
await self._auth_handler.complete_sso_login(
user_id, request, client_redirect_url, extra_attributes
)

def _generate_oidc_session_token(
self,
Expand Down Expand Up @@ -856,14 +866,11 @@ async def _map_userinfo_to_user(
The mxid of the user
"""
try:
remote_user_id = self._user_mapping_provider.get_remote_user_id(userinfo)
remote_user_id = self._remote_id_from_userinfo(userinfo)
except Exception as e:
raise MappingException(
"Failed to extract subject from OIDC response: %s" % (e,)
)
# Some OIDC providers use integer IDs, but Synapse expects external IDs
# to be strings.
remote_user_id = str(remote_user_id)

# Older mapping providers don't accept the `failures` argument, so we
# try and detect support.
Expand Down Expand Up @@ -933,6 +940,19 @@ async def grandfather_existing_users() -> Optional[str]:
grandfather_existing_users,
)

def _remote_id_from_userinfo(self, userinfo: UserInfo) -> str:
"""Extract the unique remote id from an OIDC UserInfo block

Args:
userinfo: An object representing the user given by the OIDC provider
Returns:
remote user id
"""
remote_user_id = self._user_mapping_provider.get_remote_user_id(userinfo)
# Some OIDC providers use integer IDs, but Synapse expects external IDs
# to be strings.
return str(remote_user_id)


UserAttributeDict = TypedDict(
"UserAttributeDict", {"localpart": str, "display_name": Optional[str]}
Expand Down
64 changes: 49 additions & 15 deletions synapse/handlers/saml_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,24 @@ async def handle_saml_response(self, request: SynapseRequest) -> None:
saml2_auth.in_response_to, None
)

# first check if we're doing a UIA
if current_session and current_session.ui_auth_session_id:
try:
remote_user_id = self._remote_id_from_saml_response(saml2_auth, None)
except MappingException as e:
logger.exception("Failed to extract remote user id from SAML response")
self._sso_handler.render_error(request, "mapping_error", str(e))
return

return await self._sso_handler.complete_sso_ui_auth_request(
self._auth_provider_id,
remote_user_id,
current_session.ui_auth_session_id,
request,
)

# otherwise, we're handling a login request.

# Ensure that the attributes of the logged in user meet the required
# attributes.
for requirement in self._saml2_attribute_requirements:
Expand All @@ -206,14 +224,7 @@ async def handle_saml_response(self, request: SynapseRequest) -> None:
self._sso_handler.render_error(request, "mapping_error", str(e))
return

# Complete the interactive auth session or the login.
if current_session and current_session.ui_auth_session_id:
await self._auth_handler.complete_sso_ui_auth(
user_id, current_session.ui_auth_session_id, request
)

else:
await self._auth_handler.complete_sso_login(user_id, request, relay_state)
await self._auth_handler.complete_sso_login(user_id, request, relay_state)

async def _map_saml_response_to_user(
self,
Expand All @@ -239,16 +250,10 @@ async def _map_saml_response_to_user(
RedirectException: some mapping providers may raise this if they need
to redirect to an interstitial page.
"""

remote_user_id = self._user_mapping_provider.get_remote_user_id(
remote_user_id = self._remote_id_from_saml_response(
saml2_auth, client_redirect_url
)

if not remote_user_id:
raise MappingException(
"Failed to extract remote user id from SAML response"
)

async def saml_response_to_remapped_user_attributes(
failures: int,
) -> UserAttributes:
Expand Down Expand Up @@ -304,6 +309,35 @@ async def grandfather_existing_users() -> Optional[str]:
grandfather_existing_users,
)

def _remote_id_from_saml_response(
self,
saml2_auth: saml2.response.AuthnResponse,
client_redirect_url: Optional[str],
) -> str:
"""Extract the unique remote id from a SAML2 AuthnResponse

Args:
saml2_auth: The parsed SAML2 response.
client_redirect_url: The redirect URL passed in by the client.
Returns:
remote user id

Raises:
MappingException if there was an error extracting the user id
"""
# It's not obvious why we need to pass in the redirect URI to the mapping
# provider, but we do :/
Comment on lines +328 to +329
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you see any use for it? I don't think any of the custom mapping providers I know of use it. I guess there isn't a huge harm in keeping it though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I don't see any use for it, but changing the api for the mapping providers is annoying.

remote_user_id = self._user_mapping_provider.get_remote_user_id(
saml2_auth, client_redirect_url
)

if not remote_user_id:
raise MappingException(
"Failed to extract remote user id from SAML response"
)

return remote_user_id

def expire_sessions(self):
expire_before = self.clock.time_msec() - self._saml2_session_lifetime
to_expire = set()
Expand Down
59 changes: 51 additions & 8 deletions synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@

import attr

from twisted.web.http import Request

from synapse.api.errors import RedirectException
from synapse.handlers._base import BaseHandler
from synapse.http.server import respond_with_html
from synapse.types import UserID, contains_invalid_mxid_characters

Expand All @@ -42,14 +43,16 @@ class UserAttributes:
emails = attr.ib(type=List[str], default=attr.Factory(list))


class SsoHandler(BaseHandler):
class SsoHandler:
# The number of attempts to ask the mapping provider for when generating an MXID.
_MAP_USERNAME_RETRIES = 1000

def __init__(self, hs: "HomeServer"):
super().__init__(hs)
self._store = hs.get_datastore()
self._server_name = hs.hostname
self._registration_handler = hs.get_registration_handler()
self._error_template = hs.config.sso_error_template
self._auth_handler = hs.get_auth_handler()

def render_error(
self, request, error: str, error_description: Optional[str] = None
Expand Down Expand Up @@ -95,7 +98,7 @@ async def get_sso_user_by_remote_user_id(
)

# Check if we already have a mapping for this user.
previously_registered_user_id = await self.store.get_user_by_external_id(
previously_registered_user_id = await self._store.get_user_by_external_id(
auth_provider_id, remote_user_id,
)

Expand Down Expand Up @@ -181,7 +184,7 @@ async def get_mxid_from_sso(
previously_registered_user_id = await grandfather_existing_users()
if previously_registered_user_id:
# Future logins should also match this user ID.
await self.store.record_user_external_id(
await self._store.record_user_external_id(
auth_provider_id, remote_user_id, previously_registered_user_id
)
return previously_registered_user_id
Expand Down Expand Up @@ -214,8 +217,8 @@ async def get_mxid_from_sso(
)

# Check if this mxid already exists
user_id = UserID(attributes.localpart, self.server_name).to_string()
if not await self.store.get_users_by_id_case_insensitive(user_id):
user_id = UserID(attributes.localpart, self._server_name).to_string()
if not await self._store.get_users_by_id_case_insensitive(user_id):
# This mxid is free
break
else:
Expand All @@ -238,7 +241,47 @@ async def get_mxid_from_sso(
user_agent_ips=[(user_agent, ip_address)],
)

await self.store.record_user_external_id(
await self._store.record_user_external_id(
auth_provider_id, remote_user_id, registered_user_id
)
return registered_user_id

async def complete_sso_ui_auth_request(
self,
auth_provider_id: str,
remote_user_id: str,
ui_auth_session_id: str,
request: Request,
) -> None:
"""
Given an SSO ID, retrieve the user ID for it and complete UIA.

Note that this requires that the user is mapped in the "user_external_ids"
table. This will be the case if they have ever logged in via SAML or OIDC in
recentish synapse versions, but may not be for older users.

Args:
auth_provider_id: A unique identifier for this SSO provider, e.g.
"oidc" or "saml".
remote_user_id: The unique identifier from the SSO provider.
ui_auth_session_id: The ID of the user-interactive auth session.
request: The request to complete.
"""

user_id = await self.get_sso_user_by_remote_user_id(
auth_provider_id, remote_user_id,
)

if not user_id:
logger.warning(
"Remote user %s/%s has not previously logged in here: UIA will fail",
auth_provider_id,
remote_user_id,
)
# Let the UIA flow handle this the same as if they presented creds for a
# different user.
Comment on lines +281 to +282
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to end up storing "" into the database table for the user credentials. Then eventually gets compared to requester.user.to_string(), which fails. So seems sane.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(To be clear -- I was just wondering how this worked and figured I'd save the trouble of anyone else looking at this PR.)

user_id = ""

await self._auth_handler.complete_sso_ui_auth(
user_id, ui_auth_session_id, request
)