Implement MSC2290 (#6043)

Implements MSC2290. This PR adds two new endpoints, /unstable/account/3pid/add and /unstable/account/3pid/bind. Depending on the progress of that MSC the unstable prefix may go away.

This PR also removes the blacklist on some 3PID tests which occurs in #6042, as the corresponding Sytest PR changes them to use the new endpoints.

Finally, it also modifies the account deactivation code such that it doesn't just try to deactivate 3PIDs that were bound to the user's account, but any 3PIDs that were bound through the homeserver on that user's account.
This commit is contained in:
Andrew Morgan 2019-09-23 17:50:27 +02:00 committed by Richard van der Hoff
parent 1b519e0272
commit 30af161af2
7 changed files with 204 additions and 135 deletions

1
changelog.d/6043.feature Normal file
View File

@ -0,0 +1 @@
Implement new Client Server API endpoints `/account/3pid/add` and `/account/3pid/bind` as per [MSC2290](https://github.com/matrix-org/matrix-doc/pull/2290).

View File

@ -73,7 +73,9 @@ class DeactivateAccountHandler(BaseHandler):
# unbinding # unbinding
identity_server_supports_unbinding = True identity_server_supports_unbinding = True
threepids = yield self.store.user_get_threepids(user_id) # Retrieve the 3PIDs this user has bound to an identity server
threepids = yield self.store.user_get_bound_threepids(user_id)
for threepid in threepids: for threepid in threepids:
try: try:
result = yield self._identity_handler.try_unbind_threepid( result = yield self._identity_handler.try_unbind_threepid(

View File

@ -30,6 +30,7 @@ from synapse.api.errors import (
HttpResponseException, HttpResponseException,
SynapseError, SynapseError,
) )
from synapse.config.emailconfig import ThreepidBehaviour
from synapse.util.stringutils import random_string from synapse.util.stringutils import random_string
from ._base import BaseHandler from ._base import BaseHandler
@ -45,36 +46,6 @@ class IdentityHandler(BaseHandler):
self.federation_http_client = hs.get_http_client() self.federation_http_client = hs.get_http_client()
self.hs = hs self.hs = hs
def _extract_items_from_creds_dict(self, creds):
"""
Retrieve entries from a "credentials" dictionary
Args:
creds (dict[str, str]): Dictionary of credentials that contain the following keys:
* client_secret|clientSecret: A unique secret str provided by the client
* id_server|idServer: the domain of the identity server to query
* id_access_token: The access token to authenticate to the identity
server with.
Returns:
tuple(str, str, str|None): A tuple containing the client_secret, the id_server,
and the id_access_token value if available.
"""
client_secret = creds.get("client_secret") or creds.get("clientSecret")
if not client_secret:
raise SynapseError(
400, "No client_secret in creds", errcode=Codes.MISSING_PARAM
)
id_server = creds.get("id_server") or creds.get("idServer")
if not id_server:
raise SynapseError(
400, "No id_server in creds", errcode=Codes.MISSING_PARAM
)
id_access_token = creds.get("id_access_token")
return client_secret, id_server, id_access_token
@defer.inlineCallbacks @defer.inlineCallbacks
def threepid_from_creds(self, id_server, creds): def threepid_from_creds(self, id_server, creds):
""" """
@ -113,35 +84,50 @@ class IdentityHandler(BaseHandler):
data = yield self.http_client.get_json(url, query_params) data = yield self.http_client.get_json(url, query_params)
except TimeoutError: except TimeoutError:
raise SynapseError(500, "Timed out contacting identity server") raise SynapseError(500, "Timed out contacting identity server")
return data if "medium" in data else None except HttpResponseException as e:
logger.info(
"%s returned %i for threepid validation for: %s",
id_server,
e.code,
creds,
)
return None
# Old versions of Sydent return a 200 http code even on a failed validation
# check. Thus, in addition to the HttpResponseException check above (which
# checks for non-200 errors), we need to make sure validation_session isn't
# actually an error, identified by the absence of a "medium" key
# See https://github.com/matrix-org/sydent/issues/215 for details
if "medium" in data:
return data
logger.info("%s reported non-validated threepid: %s", id_server, creds)
return None
@defer.inlineCallbacks @defer.inlineCallbacks
def bind_threepid(self, creds, mxid, use_v2=True): def bind_threepid(
self, client_secret, sid, mxid, id_server, id_access_token=None, use_v2=True
):
"""Bind a 3PID to an identity server """Bind a 3PID to an identity server
Args: Args:
creds (dict[str, str]): Dictionary of credentials that contain the following keys: client_secret (str): A unique secret provided by the client
* client_secret|clientSecret: A unique secret str provided by the client
* id_server|idServer: the domain of the identity server to query sid (str): The ID of the validation session
* id_access_token: The access token to authenticate to the identity
server with. Required if use_v2 is true
mxid (str): The MXID to bind the 3PID to mxid (str): The MXID to bind the 3PID to
use_v2 (bool): Whether to use v2 Identity Service API endpoints
id_server (str): The domain of the identity server to query
id_access_token (str): The access token to authenticate to the identity
server with, if necessary. Required if use_v2 is true
use_v2 (bool): Whether to use v2 Identity Service API endpoints. Defaults to True
Returns: Returns:
Deferred[dict]: The response from the identity server Deferred[dict]: The response from the identity server
""" """
logger.debug("binding threepid %r to %s", creds, mxid) logger.debug("Proxying threepid bind request for %s to %s", mxid, id_server)
client_secret, id_server, id_access_token = self._extract_items_from_creds_dict(
creds
)
sid = creds.get("sid")
if not sid:
raise SynapseError(
400, "No sid in three_pid_creds", errcode=Codes.MISSING_PARAM
)
# If an id_access_token is not supplied, force usage of v1 # If an id_access_token is not supplied, force usage of v1
if id_access_token is None: if id_access_token is None:
@ -160,7 +146,6 @@ class IdentityHandler(BaseHandler):
data = yield self.http_client.post_json_get_json( data = yield self.http_client.post_json_get_json(
bind_url, bind_data, headers=headers bind_url, bind_data, headers=headers
) )
logger.debug("bound threepid %r to %s", creds, mxid)
# Remember where we bound the threepid # Remember where we bound the threepid
yield self.store.add_user_bound_threepid( yield self.store.add_user_bound_threepid(
@ -182,7 +167,10 @@ class IdentityHandler(BaseHandler):
return data return data
logger.info("Got 404 when POSTing JSON %s, falling back to v1 URL", bind_url) logger.info("Got 404 when POSTing JSON %s, falling back to v1 URL", bind_url)
return (yield self.bind_threepid(creds, mxid, use_v2=False)) res = yield self.bind_threepid(
client_secret, sid, mxid, id_server, id_access_token, use_v2=False
)
return res
@defer.inlineCallbacks @defer.inlineCallbacks
def try_unbind_threepid(self, mxid, threepid): def try_unbind_threepid(self, mxid, threepid):
@ -459,6 +447,50 @@ class IdentityHandler(BaseHandler):
except TimeoutError: except TimeoutError:
raise SynapseError(500, "Timed out contacting identity server") raise SynapseError(500, "Timed out contacting identity server")
@defer.inlineCallbacks
def validate_threepid_session(self, client_secret, sid):
"""Validates a threepid session with only the client secret and session ID
Tries validating against any configured account_threepid_delegates as well as locally.
Args:
client_secret (str): A secret provided by the client
sid (str): The ID of the session
Returns:
Dict[str, str|int] if validation was successful, otherwise None
"""
# XXX: We shouldn't need to keep wrapping and unwrapping this value
threepid_creds = {"client_secret": client_secret, "sid": sid}
# We don't actually know which medium this 3PID is. Thus we first assume it's email,
# and if validation fails we try msisdn
validation_session = None
# Try to validate as email
if self.hs.config.threepid_behaviour_email == ThreepidBehaviour.REMOTE:
# Ask our delegated email identity server
validation_session = yield self.threepid_from_creds(
self.hs.config.account_threepid_delegate_email, threepid_creds
)
elif self.hs.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL:
# Get a validated session matching these details
validation_session = yield self.store.get_threepid_validation_session(
"email", client_secret, sid=sid, validated=True
)
if validation_session:
return validation_session
# Try to validate as msisdn
if self.hs.config.account_threepid_delegate_msisdn:
# Ask our delegated msisdn identity server
validation_session = yield self.threepid_from_creds(
self.hs.config.account_threepid_delegate_msisdn, threepid_creds
)
return validation_session
def create_id_access_token_header(id_access_token): def create_id_access_token_header(id_access_token):
"""Create an Authorization header for passing to SimpleHttpClient as the header value """Create an Authorization header for passing to SimpleHttpClient as the header value

View File

@ -21,12 +21,7 @@ from six.moves import http_client
from twisted.internet import defer from twisted.internet import defer
from synapse.api.constants import LoginType from synapse.api.constants import LoginType
from synapse.api.errors import ( from synapse.api.errors import Codes, SynapseError, ThreepidValidationError
Codes,
HttpResponseException,
SynapseError,
ThreepidValidationError,
)
from synapse.config.emailconfig import ThreepidBehaviour from synapse.config.emailconfig import ThreepidBehaviour
from synapse.http.server import finish_request from synapse.http.server import finish_request
from synapse.http.servlet import ( from synapse.http.servlet import (
@ -485,10 +480,8 @@ class MsisdnThreepidRequestTokenRestServlet(RestServlet):
def on_POST(self, request): def on_POST(self, request):
body = parse_json_object_from_request(request) body = parse_json_object_from_request(request)
assert_params_in_dict( assert_params_in_dict(
body, body, ["client_secret", "country", "phone_number", "send_attempt"]
["id_server", "client_secret", "country", "phone_number", "send_attempt"],
) )
id_server = "https://" + body["id_server"] # Assume https
client_secret = body["client_secret"] client_secret = body["client_secret"]
country = body["country"] country = body["country"]
phone_number = body["phone_number"] phone_number = body["phone_number"]
@ -509,8 +502,23 @@ class MsisdnThreepidRequestTokenRestServlet(RestServlet):
if existing_user_id is not None: if existing_user_id is not None:
raise SynapseError(400, "MSISDN is already in use", Codes.THREEPID_IN_USE) raise SynapseError(400, "MSISDN is already in use", Codes.THREEPID_IN_USE)
if not self.hs.config.account_threepid_delegate_msisdn:
logger.warn(
"No upstream msisdn account_threepid_delegate configured on the server to "
"handle this request"
)
raise SynapseError(
400,
"Adding phone numbers to user account is not supported by this homeserver",
)
ret = yield self.identity_handler.requestMsisdnToken( ret = yield self.identity_handler.requestMsisdnToken(
id_server, country, phone_number, client_secret, send_attempt, next_link self.hs.config.account_threepid_delegate_msisdn,
country,
phone_number,
client_secret,
send_attempt,
next_link,
) )
return 200, ret return 200, ret
@ -627,80 +635,87 @@ class ThreepidRestServlet(RestServlet):
client_secret = threepid_creds["client_secret"] client_secret = threepid_creds["client_secret"]
sid = threepid_creds["sid"] sid = threepid_creds["sid"]
# We don't actually know which medium this 3PID is. Thus we first assume it's email, validation_session = yield self.identity_handler.validate_threepid_session(
# and if validation fails we try msisdn client_secret, sid
validation_session = None
# Try to validate as email
if self.hs.config.threepid_behaviour_email == ThreepidBehaviour.REMOTE:
# Ask our delegated email identity server
try:
validation_session = yield self.identity_handler.threepid_from_creds(
self.hs.config.account_threepid_delegate_email, threepid_creds
) )
except HttpResponseException: if validation_session:
logger.debug(
"%s reported non-validated threepid: %s",
self.hs.config.account_threepid_delegate_email,
threepid_creds,
)
elif self.hs.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL:
# Get a validated session matching these details
validation_session = yield self.datastore.get_threepid_validation_session(
"email", client_secret, sid=sid, validated=True
)
# Old versions of Sydent return a 200 http code even on a failed validation check.
# Thus, in addition to the HttpResponseException check above (which checks for
# non-200 errors), we need to make sure validation_session isn't actually an error,
# identified by containing an "error" key
# See https://github.com/matrix-org/sydent/issues/215 for details
if validation_session and "error" not in validation_session:
yield self._add_threepid_to_account(user_id, validation_session)
return 200, {}
# Try to validate as msisdn
if self.hs.config.account_threepid_delegate_msisdn:
# Ask our delegated msisdn identity server
try:
validation_session = yield self.identity_handler.threepid_from_creds(
self.hs.config.account_threepid_delegate_msisdn, threepid_creds
)
except HttpResponseException:
logger.debug(
"%s reported non-validated threepid: %s",
self.hs.config.account_threepid_delegate_email,
threepid_creds,
)
# Check that validation_session isn't actually an error due to old Sydent instances
# See explanatory comment above
if validation_session and "error" not in validation_session:
yield self._add_threepid_to_account(user_id, validation_session)
return 200, {}
raise SynapseError(
400, "No validated 3pid session found", Codes.THREEPID_AUTH_FAILED
)
@defer.inlineCallbacks
def _add_threepid_to_account(self, user_id, validation_session):
"""Add a threepid wrapped in a validation_session dict to an account
Args:
user_id (str): The mxid of the user to add this 3PID to
validation_session (dict): A dict containing the following:
* medium - medium of the threepid
* address - address of the threepid
* validated_at - timestamp of when the validation occurred
"""
yield self.auth_handler.add_threepid( yield self.auth_handler.add_threepid(
user_id, user_id,
validation_session["medium"], validation_session["medium"],
validation_session["address"], validation_session["address"],
validation_session["validated_at"], validation_session["validated_at"],
) )
return 200, {}
raise SynapseError(
400, "No validated 3pid session found", Codes.THREEPID_AUTH_FAILED
)
class ThreepidAddRestServlet(RestServlet):
PATTERNS = client_patterns("/account/3pid/add$", releases=(), unstable=True)
def __init__(self, hs):
super(ThreepidAddRestServlet, self).__init__()
self.hs = hs
self.identity_handler = hs.get_handlers().identity_handler
self.auth = hs.get_auth()
self.auth_handler = hs.get_auth_handler()
@defer.inlineCallbacks
def on_POST(self, request):
requester = yield self.auth.get_user_by_req(request)
user_id = requester.user.to_string()
body = parse_json_object_from_request(request)
assert_params_in_dict(body, ["client_secret", "sid"])
client_secret = body["client_secret"]
sid = body["sid"]
validation_session = yield self.identity_handler.validate_threepid_session(
client_secret, sid
)
if validation_session:
yield self.auth_handler.add_threepid(
user_id,
validation_session["medium"],
validation_session["address"],
validation_session["validated_at"],
)
return 200, {}
raise SynapseError(
400, "No validated 3pid session found", Codes.THREEPID_AUTH_FAILED
)
class ThreepidBindRestServlet(RestServlet):
PATTERNS = client_patterns("/account/3pid/bind$", releases=(), unstable=True)
def __init__(self, hs):
super(ThreepidBindRestServlet, self).__init__()
self.hs = hs
self.identity_handler = hs.get_handlers().identity_handler
self.auth = hs.get_auth()
@defer.inlineCallbacks
def on_POST(self, request):
body = parse_json_object_from_request(request)
assert_params_in_dict(body, ["id_server", "sid", "client_secret"])
id_server = body["id_server"]
sid = body["sid"]
client_secret = body["client_secret"]
id_access_token = body.get("id_access_token") # optional
requester = yield self.auth.get_user_by_req(request)
user_id = requester.user.to_string()
yield self.identity_handler.bind_threepid(
client_secret, sid, user_id, id_server, id_access_token
)
return 200, {}
class ThreepidUnbindRestServlet(RestServlet): class ThreepidUnbindRestServlet(RestServlet):
@ -794,6 +809,8 @@ def register_servlets(hs, http_server):
MsisdnThreepidRequestTokenRestServlet(hs).register(http_server) MsisdnThreepidRequestTokenRestServlet(hs).register(http_server)
AddThreepidSubmitTokenServlet(hs).register(http_server) AddThreepidSubmitTokenServlet(hs).register(http_server)
ThreepidRestServlet(hs).register(http_server) ThreepidRestServlet(hs).register(http_server)
ThreepidAddRestServlet(hs).register(http_server)
ThreepidBindRestServlet(hs).register(http_server)
ThreepidUnbindRestServlet(hs).register(http_server) ThreepidUnbindRestServlet(hs).register(http_server)
ThreepidDeleteRestServlet(hs).register(http_server) ThreepidDeleteRestServlet(hs).register(http_server)
WhoamiRestServlet(hs).register(http_server) WhoamiRestServlet(hs).register(http_server)

View File

@ -246,6 +246,12 @@ class RegistrationSubmitTokenServlet(RestServlet):
[self.config.email_registration_template_failure_html], [self.config.email_registration_template_failure_html],
) )
if self.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL:
self.failure_email_template, = load_jinja2_templates(
self.config.email_template_dir,
[self.config.email_registration_template_failure_html],
)
@defer.inlineCallbacks @defer.inlineCallbacks
def on_GET(self, request, medium): def on_GET(self, request, medium):
if medium != "email": if medium != "email":

View File

@ -586,6 +586,26 @@ class RegistrationWorkerStore(SQLBaseStore):
desc="add_user_bound_threepid", desc="add_user_bound_threepid",
) )
def user_get_bound_threepids(self, user_id):
"""Get the threepids that a user has bound to an identity server through the homeserver
The homeserver remembers where binds to an identity server occurred. Using this
method can retrieve those threepids.
Args:
user_id (str): The ID of the user to retrieve threepids for
Returns:
Deferred[list[dict]]: List of dictionaries containing the following:
medium (str): The medium of the threepid (e.g "email")
address (str): The address of the threepid (e.g "bob@example.com")
"""
return self._simple_select_list(
table="user_threepid_id_server",
keyvalues={"user_id": user_id},
retcols=["medium", "address"],
desc="user_get_bound_threepids",
)
def remove_user_bound_threepid(self, user_id, medium, address, id_server): def remove_user_bound_threepid(self, user_id, medium, address, id_server):
"""The server proxied an unbind request to the given identity server on """The server proxied an unbind request to the given identity server on
behalf of the given user, so we remove the mapping of threepid to behalf of the given user, so we remove the mapping of threepid to
@ -655,7 +675,7 @@ class RegistrationWorkerStore(SQLBaseStore):
self, medium, client_secret, address=None, sid=None, validated=True self, medium, client_secret, address=None, sid=None, validated=True
): ):
"""Gets a session_id and last_send_attempt (if available) for a """Gets a session_id and last_send_attempt (if available) for a
client_secret/medium/(address|session_id) combo combination of validation metadata
Args: Args:
medium (str|None): The medium of the 3PID medium (str|None): The medium of the 3PID

View File

@ -29,12 +29,3 @@ Enabling an unknown default rule fails with 404
# Blacklisted due to https://github.com/matrix-org/synapse/issues/1663 # Blacklisted due to https://github.com/matrix-org/synapse/issues/1663
New federated private chats get full presence information (SYN-115) New federated private chats get full presence information (SYN-115)
# Blacklisted temporarily due to https://github.com/matrix-org/matrix-doc/pull/2290
# These sytests need to be updated with new endpoints, which will come in a later PR
# That PR will also remove this blacklist
Can bind 3PID via home server
Can bind and unbind 3PID via homeserver
3PIDs are unbound after account deactivation
Can bind and unbind 3PID via /unbind by specifying the identity server
Can bind and unbind 3PID via /unbind without specifying the identity server