diff --git a/changelog.d/51.feature b/changelog.d/51.feature new file mode 100644 index 0000000000..e5c9990ad6 --- /dev/null +++ b/changelog.d/51.feature @@ -0,0 +1 @@ +Add `bind_new_user_emails_to_sydent` option for automatically binding user's emails after registration. diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index 060c74c4a8..847926c146 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -1387,6 +1387,24 @@ account_threepid_delegates: #rewrite_identity_server_urls: # "https://somewhere.example.com": "https://somewhereelse.example.com" +# When a user registers an account with an email address, it can be useful to +# bind that email address to their mxid on an identity server. Typically, this +# requires the user to validate their email address with the identity server. +# However if Synapse itself is handling email validation on registration, the +# user ends up needing to validate their email twice, which leads to poor UX. +# +# It is possible to force Sydent, one identity server implementation, to bind +# threepids using its internal, unauthenticated bind API: +# https://github.com/matrix-org/sydent/#internal-bind-and-unbind-api +# +# Configure the address of a Sydent server here to have Synapse attempt +# to automatically bind users' emails following registration. The +# internal bind API must be reachable from Synapse, but should NOT be +# exposed to any third party, as it allows the creation of bindings +# without validation. +# +#bind_new_user_emails_to_sydent: https://example.com:8091 + ## Metrics ### diff --git a/synapse/config/registration.py b/synapse/config/registration.py index a46b3ef53e..43b87e9a70 100644 --- a/synapse/config/registration.py +++ b/synapse/config/registration.py @@ -177,6 +177,23 @@ def read_config(self, config, **kwargs): session_lifetime = self.parse_duration(session_lifetime) self.session_lifetime = session_lifetime + self.bind_new_user_emails_to_sydent = config.get( + "bind_new_user_emails_to_sydent" + ) + + if self.bind_new_user_emails_to_sydent: + if not isinstance( + self.bind_new_user_emails_to_sydent, str + ) or not self.bind_new_user_emails_to_sydent.startswith("http"): + raise ConfigError( + "Option bind_new_user_emails_to_sydent has invalid value" + ) + + # Remove trailing slashes + self.bind_new_user_emails_to_sydent = self.bind_new_user_emails_to_sydent.strip( + "/" + ) + def generate_config_section(self, generate_secrets=False, **kwargs): if generate_secrets: registration_shared_secret = 'registration_shared_secret: "%s"' % ( @@ -469,6 +486,24 @@ def generate_config_section(self, generate_secrets=False, **kwargs): # #rewrite_identity_server_urls: # "https://somewhere.example.com": "https://somewhereelse.example.com" + + # When a user registers an account with an email address, it can be useful to + # bind that email address to their mxid on an identity server. Typically, this + # requires the user to validate their email address with the identity server. + # However if Synapse itself is handling email validation on registration, the + # user ends up needing to validate their email twice, which leads to poor UX. + # + # It is possible to force Sydent, one identity server implementation, to bind + # threepids using its internal, unauthenticated bind API: + # https://github.com/matrix-org/sydent/#internal-bind-and-unbind-api + # + # Configure the address of a Sydent server here to have Synapse attempt + # to automatically bind users' emails following registration. The + # internal bind API must be reachable from Synapse, but should NOT be + # exposed to any third party, as it allows the creation of bindings + # without validation. + # + #bind_new_user_emails_to_sydent: https://example.com:8091 """ % locals() ) diff --git a/synapse/handlers/identity.py b/synapse/handlers/identity.py index 6039034c00..a77088e295 100644 --- a/synapse/handlers/identity.py +++ b/synapse/handlers/identity.py @@ -1033,6 +1033,30 @@ async def ask_id_server_for_third_party_invite( display_name = data["display_name"] return token, public_keys, fallback_public_key, display_name + async def bind_email_using_internal_sydent_api( + self, id_server_url: str, email: str, user_id: str, + ): + """Bind an email to a fully qualified user ID using the internal API of an + instance of Sydent. + + Args: + id_server_url: The URL of the Sydent instance + email: The email address to bind + user_id: The user ID to bind the email to + + Raises: + HTTPResponseException: On a non-2xx HTTP response. + """ + # id_server_url is assumed to have no trailing slashes + url = id_server_url + "/_matrix/identity/internal/bind" + body = { + "address": email, + "medium": "email", + "mxid": user_id, + } + + await self.http_client.post_json_get_json(url, body) + def create_id_access_token_header(id_access_token): """Create an Authorization header for passing to SimpleHttpClient as the header value diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py index d5d44de8d0..99c1a78fd0 100644 --- a/synapse/handlers/register.py +++ b/synapse/handlers/register.py @@ -636,6 +636,7 @@ async def post_registration_actions(self, user_id, auth_result, access_token): if auth_result and LoginType.EMAIL_IDENTITY in auth_result: threepid = auth_result[LoginType.EMAIL_IDENTITY] + # Necessary due to auth checks prior to the threepid being # written to the db if is_threepid_reserved( @@ -645,34 +646,30 @@ async def post_registration_actions(self, user_id, auth_result, access_token): await self.register_email_threepid(user_id, threepid, access_token) - if self.hs.config.account_threepid_delegate_email: - # Bind the 3PID to the identity server + if self.hs.config.bind_new_user_emails_to_sydent: + # Attempt to call Sydent's internal bind API on the given identity server + # to bind this threepid + id_server_url = self.hs.config.bind_new_user_emails_to_sydent + logger.debug( - "Binding email to %s on id_server %s", + "Attempting the bind email of %s to identity server: %s using " + "internal Sydent bind API.", user_id, - self.hs.config.account_threepid_delegate_email, + self.hs.config.bind_new_user_emails_to_sydent, ) - threepid_creds = threepid["threepid_creds"] - - # Remove the protocol scheme before handling to `bind_threepid` - # `bind_threepid` will add https:// to it, so this restricts - # account_threepid_delegate.email to https:// addresses only - # We assume this is always the case for dinsic however. - if self.hs.config.account_threepid_delegate_email.startswith( - "https://" - ): - id_server = self.hs.config.account_threepid_delegate_email[8:] - else: - # Must start with http:// instead - id_server = self.hs.config.account_threepid_delegate_email[7:] - await self.identity_handler.bind_threepid( - threepid_creds["client_secret"], - threepid_creds["sid"], - user_id, - id_server, - threepid_creds.get("id_access_token"), - ) + try: + await self.identity_handler.bind_email_using_internal_sydent_api( + id_server_url, threepid["address"], user_id + ) + except Exception as e: + logger.warning( + "Failed to bind email of '%s' to Sydent instance '%s' ", + "using Sydent internal bind API: %s", + user_id, + id_server_url, + e, + ) if auth_result and LoginType.MSISDN in auth_result: threepid = auth_result[LoginType.MSISDN] diff --git a/tests/handlers/test_register.py b/tests/handlers/test_register.py index 2a377a4eb9..a7f52067d0 100644 --- a/tests/handlers/test_register.py +++ b/tests/handlers/test_register.py @@ -20,9 +20,16 @@ from synapse.api.constants import UserTypes from synapse.api.errors import Codes, ResourceLimitError, SynapseError from synapse.handlers.register import RegistrationHandler -from synapse.rest.client.v2_alpha.register import _map_email_to_displayname +from synapse.http.site import SynapseRequest +from synapse.rest.client.v2_alpha.register import ( + _map_email_to_displayname, + register_servlets, +) from synapse.types import RoomAlias, UserID, create_requester +from tests.server import FakeChannel +from tests.unittest import override_config + from .. import unittest @@ -34,6 +41,10 @@ def __init__(self, hs): class RegistrationTestCase(unittest.HomeserverTestCase): """ Tests the RegistrationHandler. """ + servlets = [ + register_servlets, + ] + def make_homeserver(self, reactor, clock): hs_config = self.default_config() @@ -287,6 +298,78 @@ def _check_mapping(self, i, expected): result = _map_email_to_displayname(i) self.assertEqual(result, expected) + @override_config( + { + "bind_new_user_emails_to_sydent": "https://is.example.com", + "registrations_require_3pid": ["email"], + "account_threepid_delegates": {}, + "email": { + "smtp_host": "127.0.0.1", + "smtp_port": 20, + "require_transport_security": False, + "smtp_user": None, + "smtp_pass": None, + "notif_from": "test@example.com", + }, + "public_baseurl": "http://localhost", + } + ) + def test_user_email_bound_via_sydent_internal_api(self): + """Tests that emails are bound after registration if this option is set""" + # Register user with an email address + email = "alice@example.com" + + # Mock Synapse's threepid validator + get_threepid_validation_session = Mock( + return_value=defer.succeed( + {"medium": "email", "address": email, "validated_at": 0} + ) + ) + self.store.get_threepid_validation_session = get_threepid_validation_session + delete_threepid_session = Mock(return_value=defer.succeed(None)) + self.store.delete_threepid_session = delete_threepid_session + + # Mock Synapse's http json post method to check for the internal bind call + post_json_get_json = Mock(return_value=defer.succeed(None)) + self.hs.get_simple_http_client().post_json_get_json = post_json_get_json + + # Retrieve a UIA session ID + channel = self.uia_register( + 401, {"username": "alice", "password": "nobodywillguessthis"} + ) + session_id = channel.json_body["session"] + + # Register our email address using the fake validation session above + channel = self.uia_register( + 200, + { + "username": "alice", + "password": "nobodywillguessthis", + "auth": { + "session": session_id, + "type": "m.login.email.identity", + "threepid_creds": {"sid": "blabla", "client_secret": "blablabla"}, + }, + }, + ) + self.assertEqual(channel.json_body["user_id"], "@alice:test") + + # Check that a bind attempt was made to our fake identity server + post_json_get_json.assert_called_with( + "https://is.example.com/_matrix/identity/internal/bind", + {"address": "alice@example.com", "medium": "email", "mxid": "@alice:test"}, + ) + + def uia_register(self, expected_response: int, body: dict) -> FakeChannel: + """Make a register request.""" + request, channel = self.make_request( + "POST", "register", body + ) # type: SynapseRequest, FakeChannel + self.render(request) + + self.assertEqual(request.code, expected_response) + return channel + async def get_or_create_user( self, requester, localpart, displayname, password_hash=None ):