From d577e4438f77c58f251bcb0454e1af9a0f8bc55e Mon Sep 17 00:00:00 2001 From: dklimpel <5740567+dklimpel@users.noreply.github.com> Date: Tue, 12 May 2020 17:04:02 +0200 Subject: [PATCH 1/5] Add device management to admin API --- docs/admin_api/user_admin_api.rst | 211 ++++++++++++ synapse/rest/admin/__init__.py | 6 + synapse/rest/admin/users.py | 138 ++++++++ tests/rest/admin/test_user.py | 544 ++++++++++++++++++++++++++++++ 4 files changed, 899 insertions(+) diff --git a/docs/admin_api/user_admin_api.rst b/docs/admin_api/user_admin_api.rst index 859d7f99e7c8..7599019d00d4 100644 --- a/docs/admin_api/user_admin_api.rst +++ b/docs/admin_api/user_admin_api.rst @@ -1,3 +1,5 @@ +.. contents:: + Create or modify Account ======================== @@ -244,3 +246,212 @@ with a body of: } including an ``access_token`` of a server admin. + + +User devices +============ + +List all devices +---------------- +Gets information about all devices for a specific ``user_id``. + +**Parameters** + +The following query parameters are available: + +- ``user_id`` - fully qualified: for example, ``@user:server.com``. + +The following fields are possible in the JSON response body: + +- ``devices`` - An array of objects, each containing information about a device. + Devices objects contain the following fields: + + - ``device_id`` - Identifier of device. + - ``display_name`` - Display name set by the user for this device. + Absent if no name has been set. + - ``last_seen_ip`` - The IP address where this device was last seen. + (May be a few minutes out of date, for efficiency reasons). + - ``last_seen_ts`` - The timestamp (in milliseconds since the unix epoch) when this + devices was last seen. (May be a few minutes out of date, for efficiency reasons). + - ``user_id`` - Owner of device. + +**Usage** + +A standard request for query devices of an user: + +:: + + GET /_synapse/admin/v2/users//devices + + {} + + +Response: + +.. code:: json + + { + "devices": [ + { + "device_id": "QBUAZIFURK", + "display_name": "android", + "last_seen_ip": "1.2.3.4", + "last_seen_ts": 1474491775024, + "user_id": "" + }, + { + "device_id": "AUIECTSRND", + "display_name": "ios", + "last_seen_ip": "1.2.3.5", + "last_seen_ts": 1474491775025, + "user_id": "" + } + ] + } + +Delete multiple devices +------------------ +Deletes the given devices for a specific ``user_id``, and invalidates +any access token associated with them. + +**Parameters** + +The following query parameters are available: + +- ``user_id`` - fully qualified: for example, ``@user:server.com``. + +The following fields are required in the JSON request body: + +- ``devices`` - The list of device IDs to delete. + +**Usage** + +A standard request for delete devices: + +:: + + POST /_synapse/admin/v2/users//delete_devices + + { + "devices": [ + "QBUAZIFURK", + "AUIECTSRND" + ], + } + + +Response: + +.. code:: json + + {} + +Show a device +--------------- +Gets information on a single device, by ``device_id`` for a specific ``user_id``. + +**Parameters** + +The following query parameters are available: + +- ``user_id`` - fully qualified: for example, ``@user:server.com``. +- ``device_id`` - The device to retrieve. + +The following fields are possible in the JSON response body: + +- ``device_id`` - Identifier of device. +- ``display_name`` - Display name set by the user for this device. + Absent if no name has been set. +- ``last_seen_ip`` - The IP address where this device was last seen. + (May be a few minutes out of date, for efficiency reasons). +- ``last_seen_ts`` - The timestamp (in milliseconds since the unix epoch) when this + devices was last seen. (May be a few minutes out of date, for efficiency reasons). +- ``user_id`` - Owner of device. + + +**Usage** + +A standard request for get a device: + +:: + + GET /_synapse/admin/v2/users//devices/ + + {} + + +Response: + +.. code:: json + + { + "device_id": "", + "display_name": "android", + "last_seen_ip": "1.2.3.4", + "last_seen_ts": 1474491775024, + "user_id": "" + } + +Update a device +--------------- +Updates the metadata on the given ``device_id`` for a specific ``user_id``. + +**Parameters** + +The following query parameters are available: + +- ``user_id`` - fully qualified: for example, ``@user:server.com``. +- ``device_id`` - The device to update. + +The following fields are required in the JSON request body: + +- ``display_name`` - The new display name for this device. If not given, + the display name is unchanged. + +**Usage** + +A standard request for update a device: + +:: + + PUT /_synapse/admin/v2/users//devices/ + + { + "display_name": "My other phone" + } + + +Response: + +.. code:: json + + {} + +Delete a device +--------------- +Deletes the given ``device_id`` for a specific ``user_id``, +and invalidates any access token associated with it. + +**Parameters** + +The following query parameters are available: + +- ``user_id`` - fully qualified: for example, ``@user:server.com``. +- ``device_id`` - The device to delete. + +**Usage** + +A standard request for delete a device: + +:: + + DELETE /_synapse/admin/v2/users//devices/ + + {} + + +Response: + +.. code:: json + + {} diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index ed70d448a141..cc02bee47454 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -38,6 +38,9 @@ from synapse.rest.admin.users import ( AccountValidityRenewServlet, DeactivateAccountRestServlet, + DeleteDevicesRestServlet, + DeviceRestServlet, + DevicesRestServlet, ResetPasswordRestServlet, SearchUsersRestServlet, UserAdminServlet, @@ -200,6 +203,9 @@ def register_servlets(hs, http_server): UserAdminServlet(hs).register(http_server) UserRestServletV2(hs).register(http_server) UsersRestServletV2(hs).register(http_server) + DeviceRestServlet(hs).register(http_server) + DevicesRestServlet(hs).register(http_server) + DeleteDevicesRestServlet(hs).register(http_server) def register_servlets_for_client_rest_resource(hs, http_server): diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 593ce011e888..b20f801bfef8 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -286,6 +286,144 @@ async def on_PUT(self, request, user_id): return 201, ret +class DeviceRestServlet(RestServlet): + PATTERNS = ( + re.compile( + "^/_synapse/admin/v2/users/(?P[^/]*)/devices/(?P[^/]*)$" + ), + ) + + """ + Get, update or delete the given user's device + """ + + def __init__(self, hs): + """ + Args: + hs (synapse.server.HomeServer): server + """ + super(DeviceRestServlet, self).__init__() + self.hs = hs + self.auth = hs.get_auth() + self.device_handler = hs.get_device_handler() + self.store = hs.get_datastore() + + async def on_GET(self, request, user_id, device_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + device = await self.device_handler.get_device( + target_user.to_string(), device_id + ) + return 200, device + + async def on_DELETE(self, request, user_id, device_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + await self.device_handler.delete_device(target_user.to_string(), device_id) + return 200, {} + + async def on_PUT(self, request, user_id, device_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + body = parse_json_object_from_request(request, allow_empty_body=True) + await self.device_handler.update_device( + target_user.to_string(), device_id, body + ) + return 200, {} + + +class DevicesRestServlet(RestServlet): + PATTERNS = (re.compile("^/_synapse/admin/v2/users/(?P[^/]*)/devices$"),) + + """ + Retrieve the given user's devices + """ + + def __init__(self, hs): + """ + Args: + hs (synapse.server.HomeServer): server + """ + self.hs = hs + self.auth = hs.get_auth() + self.device_handler = hs.get_device_handler() + self.store = hs.get_datastore() + + async def on_GET(self, request, user_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + devices = await self.device_handler.get_devices_by_user(target_user.to_string()) + return 200, {"devices": devices} + + +class DeleteDevicesRestServlet(RestServlet): + """ + API for bulk deletion of devices. Accepts a JSON object with a devices + key which lists the device_ids to delete. + """ + + PATTERNS = ( + re.compile("^/_synapse/admin/v2/users/(?P[^/]*)/delete_devices$"), + ) + + def __init__(self, hs): + self.hs = hs + self.auth = hs.get_auth() + self.device_handler = hs.get_device_handler() + self.store = hs.get_datastore() + + async def on_POST(self, request, user_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + body = parse_json_object_from_request(request, allow_empty_body=False) + assert_params_in_dict(body, ["devices"]) + + await self.device_handler.delete_devices( + target_user.to_string(), body["devices"] + ) + return 200, {} + + class UserRegisterServlet(RestServlet): """ Attributes: diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index 6c88ab06e260..53a40a0bd8f9 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -22,6 +22,7 @@ import synapse.rest.admin from synapse.api.constants import UserTypes +from synapse.api.errors import Codes from synapse.rest.client.v1 import login from tests import unittest @@ -722,3 +723,546 @@ def test_accidental_deactivation_prevention(self): # Ensure they're still alive self.assertEqual(0, channel.json_body["deactivated"]) + + +class DeviceRestTestCase(unittest.HomeserverTestCase): + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.handler = hs.get_device_handler() + + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + self.other_user_token = self.login("user", "pass") + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.other_user_device_id = res[0]["device_id"] + + self.url = "/_synapse/admin/v2/users/%s/devices/%s" % (urllib.parse.quote( + self.other_user + ), self.other_user_device_id) + + def test_no_auth(self): + """ + Try to get a device of an user without authentication. + """ + request, channel = self.make_request("GET", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + request, channel = self.make_request("PUT", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + request, channel = self.make_request("DELETE", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self): + """ + If the user is not a server admin, an error is returned. + """ + request, channel = self.make_request( + "GET", self.url, access_token=self.other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + request, channel = self.make_request( + "PUT", self.url, access_token=self.other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + request, channel = self.make_request( + "DELETE", self.url, access_token=self.other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_user_does_not_exist(self): + """ + Tests that a lookup for a user that does not exist returns a 404 + """ + url = "/_synapse/admin/v2/users/@unknown_person:test/devices/%s" % self.other_user_device_id + + request, channel = self.make_request( + "GET", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + request, channel = self.make_request( + "PUT", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + request, channel = self.make_request( + "DELETE", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_user_is_not_local(self): + """ + Tests that a lookup for a user that is not a local returns a 400 + """ + url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices/%s" % self.other_user_device_id + + request, channel = self.make_request( + "GET", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + request, channel = self.make_request( + "PUT", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + request, channel = self.make_request( + "DELETE", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + def test_unknown_device(self): + """ + Tests that a lookup for a device that does not exist returns either 404 or 200. + """ + url = "/_synapse/admin/v2/users/%s/devices/unknown_device" % urllib.parse.quote( + self.other_user + ) + + request, channel = self.make_request( + "GET", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + request, channel = self.make_request( + "PUT", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + request, channel = self.make_request( + "DELETE", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + # Delete unknown device returns status 200 + self.assertEqual(200, channel.code, msg=channel.json_body) + + def test_update_device_too_long_display_name(self): + """ + Update a device with a display name that is invalid (too long). + """ + # Set iniital display name. + update = {"display_name": "new display"} + self.get_success(self.handler.update_device(self.other_user, self.other_user_device_id, update)) + + # Request to update a device display name with a new value that is longer than allowed. + update = { + "display_name": "a" + * (synapse.handlers.device.MAX_DEVICE_DISPLAY_NAME_LEN + 1) + } + + body = json.dumps(update) + request, channel = self.make_request( + "PUT", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"]) + + # Ensure the display name was not updated. + request, channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual("new display", channel.json_body["display_name"]) + + def test_update_no_display_name(self): + """ + Tests that a update for a device without JSON returns a 200 + """ + # Set iniital display name. + update = {"display_name": "new display"} + self.get_success(self.handler.update_device(self.other_user, self.other_user_device_id, update)) + + request, channel = self.make_request( + "PUT", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Ensure the display name was not updated. + request, channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual("new display", channel.json_body["display_name"]) + + def test_update_display_name(self): + """ + Tests a normal successful update of display name + """ + # Set new display_name + body = json.dumps({"display_name": "new displayname"}) + request, channel = self.make_request( + "PUT", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Check new display_name + request, channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual("new displayname", channel.json_body["display_name"]) + + def test_get_device(self): + """ + Tests that a normal lookup for a device is successfully + """ + request, channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(self.other_user, channel.json_body["user_id"]) + # Check that all fields are available + self.assertIn("user_id", channel.json_body) + self.assertIn("device_id", channel.json_body) + self.assertIn("display_name", channel.json_body) + self.assertIn("last_seen_ip", channel.json_body) + self.assertIn("last_seen_ts", channel.json_body) + + def test_delete_device(self): + """ + Tests that a remove of a device is successfully + """ + # Count number of devies of an user. + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + number_devices = len(res) + self.assertEqual(1, number_devices) + + # Delete device + request, channel = self.make_request( + "DELETE", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Ensure that the number of devices is decreased + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.assertEqual(number_devices - 1, len(res)) + + +class DevicesRestTestCase(unittest.HomeserverTestCase): + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + + self.url = "/_synapse/admin/v2/users/%s/devices" % urllib.parse.quote( + self.other_user + ) + + def test_no_auth(self): + """ + Try to list devices of an user without authentication. + """ + request, channel = self.make_request("GET", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self): + """ + If the user is not a server admin, an error is returned. + """ + other_user_token = self.login("user", "pass") + + request, channel = self.make_request( + "GET", self.url, access_token=other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_user_does_not_exist(self): + """ + Tests that a lookup for a user that does not exist returns a 404 + """ + url = "/_synapse/admin/v2/users/@unknown_person:test/devices" + request, channel = self.make_request( + "GET", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_user_is_not_local(self): + """ + Tests that a lookup for a user that is not a local returns a 400 + """ + url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices" + + request, channel = self.make_request( + "GET", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + def test_get_devices(self): + """ + Tests that a normal lookup for devices is successfully + """ + # Create devices + number_devices = 5 + for n in range(number_devices): + self.login("user", "pass") + + # Get devices + request, channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(number_devices, len(channel.json_body["devices"])) + self.assertEqual(self.other_user, channel.json_body["devices"][0]["user_id"]) + # Check that all fields are available + for d in channel.json_body["devices"]: + self.assertIn("user_id", d) + self.assertIn("device_id", d) + self.assertIn("display_name", d) + self.assertIn("last_seen_ip", d) + self.assertIn("last_seen_ts", d) + + +class DeleteDevicesRestTestCase(unittest.HomeserverTestCase): + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.handler = hs.get_device_handler() + + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + + self.url = "/_synapse/admin/v2/users/%s/delete_devices" % urllib.parse.quote( + self.other_user + ) + + def test_no_auth(self): + """ + Try to delete devices of an user without authentication. + """ + request, channel = self.make_request("POST", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self): + """ + If the user is not a server admin, an error is returned. + """ + other_user_token = self.login("user", "pass") + + request, channel = self.make_request( + "POST", self.url, access_token=other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_user_does_not_exist(self): + """ + Tests that a lookup for a user that does not exist returns a 404 + """ + url = "/_synapse/admin/v2/users/@unknown_person:test/delete_devices" + request, channel = self.make_request( + "POST", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_user_is_not_local(self): + """ + Tests that a lookup for a user that is not a local returns a 400 + """ + url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/delete_devices" + + request, channel = self.make_request( + "POST", + url, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + def test_unknown_devices(self): + """ + Tests that a remove of a device that does not exist returns 200. + """ + body = json.dumps({"devices": ["unknown_device1", "unknown_device2"]}) + request, channel = self.make_request( + "POST", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + # Delete unknown devices returns status 200 + self.assertEqual(200, channel.code, msg=channel.json_body) + + def test_delete_devices(self): + """ + Tests that a remove of devices is successfully + """ + + # Create devices + number_devices = 5 + for n in range(number_devices): + self.login("user", "pass") + + # Get devices + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.assertEqual(number_devices, len(res)) + + # Create list of device IDs + device_ids = [] + for d in res: + device_ids.append(str(d["device_id"])) + + # Delete devices + body = json.dumps({"devices": device_ids}) + request, channel = self.make_request( + "POST", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.assertEqual(0, len(res)) From 66de73804d199809908b608c26205b3c37f960ed Mon Sep 17 00:00:00 2001 From: dklimpel <5740567+dklimpel@users.noreply.github.com> Date: Tue, 12 May 2020 17:38:25 +0200 Subject: [PATCH 2/5] Add newsfile --- changelog.d/7481.feature | 1 + tests/rest/admin/test_user.py | 109 +++++++++++++--------------------- 2 files changed, 43 insertions(+), 67 deletions(-) create mode 100644 changelog.d/7481.feature diff --git a/changelog.d/7481.feature b/changelog.d/7481.feature new file mode 100644 index 000000000000..8ceb2727f535 --- /dev/null +++ b/changelog.d/7481.feature @@ -0,0 +1 @@ +Allow server admins to list users's devices and logout specific devices. \ No newline at end of file diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index 53a40a0bd8f9..fc72eb96ca10 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -743,9 +743,10 @@ def prepare(self, reactor, clock, hs): res = self.get_success(self.handler.get_devices_by_user(self.other_user)) self.other_user_device_id = res[0]["device_id"] - self.url = "/_synapse/admin/v2/users/%s/devices/%s" % (urllib.parse.quote( - self.other_user - ), self.other_user_device_id) + self.url = "/_synapse/admin/v2/users/%s/devices/%s" % ( + urllib.parse.quote(self.other_user), + self.other_user_device_id, + ) def test_no_auth(self): """ @@ -801,12 +802,13 @@ def test_user_does_not_exist(self): """ Tests that a lookup for a user that does not exist returns a 404 """ - url = "/_synapse/admin/v2/users/@unknown_person:test/devices/%s" % self.other_user_device_id + url = ( + "/_synapse/admin/v2/users/@unknown_person:test/devices/%s" + % self.other_user_device_id + ) request, channel = self.make_request( - "GET", - url, - access_token=self.admin_user_tok, + "GET", url, access_token=self.admin_user_tok, ) self.render(request) @@ -814,9 +816,7 @@ def test_user_does_not_exist(self): self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) request, channel = self.make_request( - "PUT", - url, - access_token=self.admin_user_tok, + "PUT", url, access_token=self.admin_user_tok, ) self.render(request) @@ -824,9 +824,7 @@ def test_user_does_not_exist(self): self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) request, channel = self.make_request( - "DELETE", - url, - access_token=self.admin_user_tok, + "DELETE", url, access_token=self.admin_user_tok, ) self.render(request) @@ -837,12 +835,13 @@ def test_user_is_not_local(self): """ Tests that a lookup for a user that is not a local returns a 400 """ - url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices/%s" % self.other_user_device_id + url = ( + "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices/%s" + % self.other_user_device_id + ) request, channel = self.make_request( - "GET", - url, - access_token=self.admin_user_tok, + "GET", url, access_token=self.admin_user_tok, ) self.render(request) @@ -850,9 +849,7 @@ def test_user_is_not_local(self): self.assertEqual("Can only lookup local users", channel.json_body["error"]) request, channel = self.make_request( - "PUT", - url, - access_token=self.admin_user_tok, + "PUT", url, access_token=self.admin_user_tok, ) self.render(request) @@ -860,9 +857,7 @@ def test_user_is_not_local(self): self.assertEqual("Can only lookup local users", channel.json_body["error"]) request, channel = self.make_request( - "DELETE", - url, - access_token=self.admin_user_tok, + "DELETE", url, access_token=self.admin_user_tok, ) self.render(request) @@ -878,9 +873,7 @@ def test_unknown_device(self): ) request, channel = self.make_request( - "GET", - url, - access_token=self.admin_user_tok, + "GET", url, access_token=self.admin_user_tok, ) self.render(request) @@ -888,18 +881,14 @@ def test_unknown_device(self): self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) request, channel = self.make_request( - "PUT", - url, - access_token=self.admin_user_tok, + "PUT", url, access_token=self.admin_user_tok, ) self.render(request) self.assertEqual(200, channel.code, msg=channel.json_body) request, channel = self.make_request( - "DELETE", - url, - access_token=self.admin_user_tok, + "DELETE", url, access_token=self.admin_user_tok, ) self.render(request) @@ -912,7 +901,11 @@ def test_update_device_too_long_display_name(self): """ # Set iniital display name. update = {"display_name": "new display"} - self.get_success(self.handler.update_device(self.other_user, self.other_user_device_id, update)) + self.get_success( + self.handler.update_device( + self.other_user, self.other_user_device_id, update + ) + ) # Request to update a device display name with a new value that is longer than allowed. update = { @@ -934,9 +927,7 @@ def test_update_device_too_long_display_name(self): # Ensure the display name was not updated. request, channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, + "GET", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -949,12 +940,14 @@ def test_update_no_display_name(self): """ # Set iniital display name. update = {"display_name": "new display"} - self.get_success(self.handler.update_device(self.other_user, self.other_user_device_id, update)) + self.get_success( + self.handler.update_device( + self.other_user, self.other_user_device_id, update + ) + ) request, channel = self.make_request( - "PUT", - self.url, - access_token=self.admin_user_tok, + "PUT", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -962,9 +955,7 @@ def test_update_no_display_name(self): # Ensure the display name was not updated. request, channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, + "GET", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -989,9 +980,7 @@ def test_update_display_name(self): # Check new display_name request, channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, + "GET", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -1003,9 +992,7 @@ def test_get_device(self): Tests that a normal lookup for a device is successfully """ request, channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, + "GET", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -1029,9 +1016,7 @@ def test_delete_device(self): # Delete device request, channel = self.make_request( - "DELETE", - self.url, - access_token=self.admin_user_tok, + "DELETE", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -1089,9 +1074,7 @@ def test_user_does_not_exist(self): """ url = "/_synapse/admin/v2/users/@unknown_person:test/devices" request, channel = self.make_request( - "GET", - url, - access_token=self.admin_user_tok, + "GET", url, access_token=self.admin_user_tok, ) self.render(request) @@ -1105,9 +1088,7 @@ def test_user_is_not_local(self): url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices" request, channel = self.make_request( - "GET", - url, - access_token=self.admin_user_tok, + "GET", url, access_token=self.admin_user_tok, ) self.render(request) @@ -1125,9 +1106,7 @@ def test_get_devices(self): # Get devices request, channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, + "GET", self.url, access_token=self.admin_user_tok, ) self.render(request) @@ -1192,9 +1171,7 @@ def test_user_does_not_exist(self): """ url = "/_synapse/admin/v2/users/@unknown_person:test/delete_devices" request, channel = self.make_request( - "POST", - url, - access_token=self.admin_user_tok, + "POST", url, access_token=self.admin_user_tok, ) self.render(request) @@ -1208,9 +1185,7 @@ def test_user_is_not_local(self): url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/delete_devices" request, channel = self.make_request( - "POST", - url, - access_token=self.admin_user_tok, + "POST", url, access_token=self.admin_user_tok, ) self.render(request) From b72e5b1c1653860a0d327cbce225f393678c2c94 Mon Sep 17 00:00:00 2001 From: dklimpel <5740567+dklimpel@users.noreply.github.com> Date: Tue, 12 May 2020 17:41:58 +0200 Subject: [PATCH 3/5] lint/typo --- tests/rest/admin/test_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index fc72eb96ca10..360d8e4dfcac 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -1106,7 +1106,7 @@ def test_get_devices(self): # Get devices request, channel = self.make_request( - "GET", self.url, access_token=self.admin_user_tok, + "GET", self.url, access_token=self.admin_user_tok, ) self.render(request) From 6bbda6407d43b0574b0634ad43fcda2239859203 Mon Sep 17 00:00:00 2001 From: dklimpel <5740567+dklimpel@users.noreply.github.com> Date: Tue, 19 May 2020 20:13:06 +0200 Subject: [PATCH 4/5] Move new classes to separate files --- changelog.d/7481.feature | 2 +- docs/admin_api/user_admin_api.rst | 110 +++--- synapse/rest/admin/__init__.py | 8 +- synapse/rest/admin/devices.py | 161 +++++++++ synapse/rest/admin/users.py | 138 -------- tests/rest/admin/test_device.py | 541 ++++++++++++++++++++++++++++++ tests/rest/admin/test_user.py | 519 ---------------------------- 7 files changed, 762 insertions(+), 717 deletions(-) create mode 100644 synapse/rest/admin/devices.py create mode 100644 tests/rest/admin/test_device.py diff --git a/changelog.d/7481.feature b/changelog.d/7481.feature index 8ceb2727f535..875d94407773 100644 --- a/changelog.d/7481.feature +++ b/changelog.d/7481.feature @@ -1 +1 @@ -Allow server admins to list users's devices and logout specific devices. \ No newline at end of file +Add admin APIs to allow server admins to list users' devices, and log out specific devices. \ No newline at end of file diff --git a/docs/admin_api/user_admin_api.rst b/docs/admin_api/user_admin_api.rst index 7599019d00d4..9331f11c85e7 100644 --- a/docs/admin_api/user_admin_api.rst +++ b/docs/admin_api/user_admin_api.rst @@ -255,26 +255,6 @@ List all devices ---------------- Gets information about all devices for a specific ``user_id``. -**Parameters** - -The following query parameters are available: - -- ``user_id`` - fully qualified: for example, ``@user:server.com``. - -The following fields are possible in the JSON response body: - -- ``devices`` - An array of objects, each containing information about a device. - Devices objects contain the following fields: - - - ``device_id`` - Identifier of device. - - ``display_name`` - Display name set by the user for this device. - Absent if no name has been set. - - ``last_seen_ip`` - The IP address where this device was last seen. - (May be a few minutes out of date, for efficiency reasons). - - ``last_seen_ts`` - The timestamp (in milliseconds since the unix epoch) when this - devices was last seen. (May be a few minutes out of date, for efficiency reasons). - - ``user_id`` - Owner of device. - **Usage** A standard request for query devices of an user: @@ -285,7 +265,6 @@ A standard request for query devices of an user: {} - Response: .. code:: json @@ -309,20 +288,30 @@ Response: ] } -Delete multiple devices ------------------- -Deletes the given devices for a specific ``user_id``, and invalidates -any access token associated with them. - **Parameters** The following query parameters are available: - ``user_id`` - fully qualified: for example, ``@user:server.com``. -The following fields are required in the JSON request body: +The following fields are possible in the JSON response body: -- ``devices`` - The list of device IDs to delete. +- ``devices`` - An array of objects, each containing information about a device. + Devices objects contain the following fields: + + - ``device_id`` - Identifier of device. + - ``display_name`` - Display name set by the user for this device. + Absent if no name has been set. + - ``last_seen_ip`` - The IP address where this device was last seen. + (May be a few minutes out of date, for efficiency reasons). + - ``last_seen_ts`` - The timestamp (in milliseconds since the unix epoch) when this + devices was last seen. (May be a few minutes out of date, for efficiency reasons). + - ``user_id`` - Owner of device. + +Delete multiple devices +------------------ +Deletes the given devices for a specific ``user_id``, and invalidates +any access token associated with them. **Usage** @@ -346,28 +335,19 @@ Response: {} -Show a device ---------------- -Gets information on a single device, by ``device_id`` for a specific ``user_id``. - **Parameters** The following query parameters are available: - ``user_id`` - fully qualified: for example, ``@user:server.com``. -- ``device_id`` - The device to retrieve. -The following fields are possible in the JSON response body: +The following fields are required in the JSON request body: -- ``device_id`` - Identifier of device. -- ``display_name`` - Display name set by the user for this device. - Absent if no name has been set. -- ``last_seen_ip`` - The IP address where this device was last seen. - (May be a few minutes out of date, for efficiency reasons). -- ``last_seen_ts`` - The timestamp (in milliseconds since the unix epoch) when this - devices was last seen. (May be a few minutes out of date, for efficiency reasons). -- ``user_id`` - Owner of device. +- ``devices`` - The list of device IDs to delete. +Show a device +--------------- +Gets information on a single device, by ``device_id`` for a specific ``user_id``. **Usage** @@ -392,21 +372,27 @@ Response: "user_id": "" } -Update a device ---------------- -Updates the metadata on the given ``device_id`` for a specific ``user_id``. - **Parameters** The following query parameters are available: - ``user_id`` - fully qualified: for example, ``@user:server.com``. -- ``device_id`` - The device to update. +- ``device_id`` - The device to retrieve. -The following fields are required in the JSON request body: +The following fields are possible in the JSON response body: -- ``display_name`` - The new display name for this device. If not given, - the display name is unchanged. +- ``device_id`` - Identifier of device. +- ``display_name`` - Display name set by the user for this device. + Absent if no name has been set. +- ``last_seen_ip`` - The IP address where this device was last seen. + (May be a few minutes out of date, for efficiency reasons). +- ``last_seen_ts`` - The timestamp (in milliseconds since the unix epoch) when this + devices was last seen. (May be a few minutes out of date, for efficiency reasons). +- ``user_id`` - Owner of device. + +Update a device +--------------- +Updates the metadata on the given ``device_id`` for a specific ``user_id``. **Usage** @@ -427,17 +413,22 @@ Response: {} -Delete a device ---------------- -Deletes the given ``device_id`` for a specific ``user_id``, -and invalidates any access token associated with it. - **Parameters** The following query parameters are available: - ``user_id`` - fully qualified: for example, ``@user:server.com``. -- ``device_id`` - The device to delete. +- ``device_id`` - The device to update. + +The following fields are required in the JSON request body: + +- ``display_name`` - The new display name for this device. If not given, + the display name is unchanged. + +Delete a device +--------------- +Deletes the given ``device_id`` for a specific ``user_id``, +and invalidates any access token associated with it. **Usage** @@ -455,3 +446,10 @@ Response: .. code:: json {} + +**Parameters** + +The following query parameters are available: + +- ``user_id`` - fully qualified: for example, ``@user:server.com``. +- ``device_id`` - The device to delete. diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index 7b5eb8a6dafb..9eda592de9f7 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -26,6 +26,11 @@ assert_requester_is_admin, historical_admin_path_patterns, ) +from synapse.rest.admin.devices import ( + DeleteDevicesRestServlet, + DeviceRestServlet, + DevicesRestServlet, +) from synapse.rest.admin.groups import DeleteGroupAdminRestServlet from synapse.rest.admin.media import ListMediaInRoom, register_servlets_for_media_repo from synapse.rest.admin.purge_room_servlet import PurgeRoomServlet @@ -39,9 +44,6 @@ from synapse.rest.admin.users import ( AccountValidityRenewServlet, DeactivateAccountRestServlet, - DeleteDevicesRestServlet, - DeviceRestServlet, - DevicesRestServlet, ResetPasswordRestServlet, SearchUsersRestServlet, UserAdminServlet, diff --git a/synapse/rest/admin/devices.py b/synapse/rest/admin/devices.py new file mode 100644 index 000000000000..8d3267733938 --- /dev/null +++ b/synapse/rest/admin/devices.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Dirk Klimpel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging +import re + +from synapse.api.errors import NotFoundError, SynapseError +from synapse.http.servlet import ( + RestServlet, + assert_params_in_dict, + parse_json_object_from_request, +) +from synapse.rest.admin._base import assert_requester_is_admin +from synapse.types import UserID + +logger = logging.getLogger(__name__) + + +class DeviceRestServlet(RestServlet): + """ + Get, update or delete the given user's device + """ + + PATTERNS = ( + re.compile( + "^/_synapse/admin/v2/users/(?P[^/]*)/devices/(?P[^/]*)$" + ), + ) + + def __init__(self, hs): + super(DeviceRestServlet, self).__init__() + self.hs = hs + self.auth = hs.get_auth() + self.device_handler = hs.get_device_handler() + self.store = hs.get_datastore() + + async def on_GET(self, request, user_id, device_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + device = await self.device_handler.get_device( + target_user.to_string(), device_id + ) + return 200, device + + async def on_DELETE(self, request, user_id, device_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + await self.device_handler.delete_device(target_user.to_string(), device_id) + return 200, {} + + async def on_PUT(self, request, user_id, device_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + body = parse_json_object_from_request(request, allow_empty_body=True) + await self.device_handler.update_device( + target_user.to_string(), device_id, body + ) + return 200, {} + + +class DevicesRestServlet(RestServlet): + """ + Retrieve the given user's devices + """ + + PATTERNS = (re.compile("^/_synapse/admin/v2/users/(?P[^/]*)/devices$"),) + + def __init__(self, hs): + """ + Args: + hs (synapse.server.HomeServer): server + """ + self.hs = hs + self.auth = hs.get_auth() + self.device_handler = hs.get_device_handler() + self.store = hs.get_datastore() + + async def on_GET(self, request, user_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + devices = await self.device_handler.get_devices_by_user(target_user.to_string()) + return 200, {"devices": devices} + + +class DeleteDevicesRestServlet(RestServlet): + """ + API for bulk deletion of devices. Accepts a JSON object with a devices + key which lists the device_ids to delete. + """ + + PATTERNS = ( + re.compile("^/_synapse/admin/v2/users/(?P[^/]*)/delete_devices$"), + ) + + def __init__(self, hs): + self.hs = hs + self.auth = hs.get_auth() + self.device_handler = hs.get_device_handler() + self.store = hs.get_datastore() + + async def on_POST(self, request, user_id): + await assert_requester_is_admin(self.auth, request) + + target_user = UserID.from_string(user_id) + if not self.hs.is_mine(target_user): + raise SynapseError(400, "Can only lookup local users") + + u = await self.store.get_user_by_id(target_user.to_string()) + if u is None: + raise NotFoundError("Unknown user") + + body = parse_json_object_from_request(request, allow_empty_body=False) + assert_params_in_dict(body, ["devices"]) + + await self.device_handler.delete_devices( + target_user.to_string(), body["devices"] + ) + return 200, {} diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index b20f801bfef8..593ce011e888 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -286,144 +286,6 @@ async def on_PUT(self, request, user_id): return 201, ret -class DeviceRestServlet(RestServlet): - PATTERNS = ( - re.compile( - "^/_synapse/admin/v2/users/(?P[^/]*)/devices/(?P[^/]*)$" - ), - ) - - """ - Get, update or delete the given user's device - """ - - def __init__(self, hs): - """ - Args: - hs (synapse.server.HomeServer): server - """ - super(DeviceRestServlet, self).__init__() - self.hs = hs - self.auth = hs.get_auth() - self.device_handler = hs.get_device_handler() - self.store = hs.get_datastore() - - async def on_GET(self, request, user_id, device_id): - await assert_requester_is_admin(self.auth, request) - - target_user = UserID.from_string(user_id) - if not self.hs.is_mine(target_user): - raise SynapseError(400, "Can only lookup local users") - - u = await self.store.get_user_by_id(target_user.to_string()) - if u is None: - raise NotFoundError("Unknown user") - - device = await self.device_handler.get_device( - target_user.to_string(), device_id - ) - return 200, device - - async def on_DELETE(self, request, user_id, device_id): - await assert_requester_is_admin(self.auth, request) - - target_user = UserID.from_string(user_id) - if not self.hs.is_mine(target_user): - raise SynapseError(400, "Can only lookup local users") - - u = await self.store.get_user_by_id(target_user.to_string()) - if u is None: - raise NotFoundError("Unknown user") - - await self.device_handler.delete_device(target_user.to_string(), device_id) - return 200, {} - - async def on_PUT(self, request, user_id, device_id): - await assert_requester_is_admin(self.auth, request) - - target_user = UserID.from_string(user_id) - if not self.hs.is_mine(target_user): - raise SynapseError(400, "Can only lookup local users") - - u = await self.store.get_user_by_id(target_user.to_string()) - if u is None: - raise NotFoundError("Unknown user") - - body = parse_json_object_from_request(request, allow_empty_body=True) - await self.device_handler.update_device( - target_user.to_string(), device_id, body - ) - return 200, {} - - -class DevicesRestServlet(RestServlet): - PATTERNS = (re.compile("^/_synapse/admin/v2/users/(?P[^/]*)/devices$"),) - - """ - Retrieve the given user's devices - """ - - def __init__(self, hs): - """ - Args: - hs (synapse.server.HomeServer): server - """ - self.hs = hs - self.auth = hs.get_auth() - self.device_handler = hs.get_device_handler() - self.store = hs.get_datastore() - - async def on_GET(self, request, user_id): - await assert_requester_is_admin(self.auth, request) - - target_user = UserID.from_string(user_id) - if not self.hs.is_mine(target_user): - raise SynapseError(400, "Can only lookup local users") - - u = await self.store.get_user_by_id(target_user.to_string()) - if u is None: - raise NotFoundError("Unknown user") - - devices = await self.device_handler.get_devices_by_user(target_user.to_string()) - return 200, {"devices": devices} - - -class DeleteDevicesRestServlet(RestServlet): - """ - API for bulk deletion of devices. Accepts a JSON object with a devices - key which lists the device_ids to delete. - """ - - PATTERNS = ( - re.compile("^/_synapse/admin/v2/users/(?P[^/]*)/delete_devices$"), - ) - - def __init__(self, hs): - self.hs = hs - self.auth = hs.get_auth() - self.device_handler = hs.get_device_handler() - self.store = hs.get_datastore() - - async def on_POST(self, request, user_id): - await assert_requester_is_admin(self.auth, request) - - target_user = UserID.from_string(user_id) - if not self.hs.is_mine(target_user): - raise SynapseError(400, "Can only lookup local users") - - u = await self.store.get_user_by_id(target_user.to_string()) - if u is None: - raise NotFoundError("Unknown user") - - body = parse_json_object_from_request(request, allow_empty_body=False) - assert_params_in_dict(body, ["devices"]) - - await self.device_handler.delete_devices( - target_user.to_string(), body["devices"] - ) - return 200, {} - - class UserRegisterServlet(RestServlet): """ Attributes: diff --git a/tests/rest/admin/test_device.py b/tests/rest/admin/test_device.py new file mode 100644 index 000000000000..faa7f381a96b --- /dev/null +++ b/tests/rest/admin/test_device.py @@ -0,0 +1,541 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Dirk Klimpel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import urllib.parse + +import synapse.rest.admin +from synapse.api.errors import Codes +from synapse.rest.client.v1 import login + +from tests import unittest + + +class DeviceRestTestCase(unittest.HomeserverTestCase): + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.handler = hs.get_device_handler() + + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + self.other_user_token = self.login("user", "pass") + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.other_user_device_id = res[0]["device_id"] + + self.url = "/_synapse/admin/v2/users/%s/devices/%s" % ( + urllib.parse.quote(self.other_user), + self.other_user_device_id, + ) + + def test_no_auth(self): + """ + Try to get a device of an user without authentication. + """ + request, channel = self.make_request("GET", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + request, channel = self.make_request("PUT", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + request, channel = self.make_request("DELETE", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self): + """ + If the user is not a server admin, an error is returned. + """ + request, channel = self.make_request( + "GET", self.url, access_token=self.other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + request, channel = self.make_request( + "PUT", self.url, access_token=self.other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + request, channel = self.make_request( + "DELETE", self.url, access_token=self.other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_user_does_not_exist(self): + """ + Tests that a lookup for a user that does not exist returns a 404 + """ + url = ( + "/_synapse/admin/v2/users/@unknown_person:test/devices/%s" + % self.other_user_device_id + ) + + request, channel = self.make_request( + "GET", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + request, channel = self.make_request( + "PUT", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + request, channel = self.make_request( + "DELETE", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_user_is_not_local(self): + """ + Tests that a lookup for a user that is not a local returns a 400 + """ + url = ( + "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices/%s" + % self.other_user_device_id + ) + + request, channel = self.make_request( + "GET", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + request, channel = self.make_request( + "PUT", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + request, channel = self.make_request( + "DELETE", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + def test_unknown_device(self): + """ + Tests that a lookup for a device that does not exist returns either 404 or 200. + """ + url = "/_synapse/admin/v2/users/%s/devices/unknown_device" % urllib.parse.quote( + self.other_user + ) + + request, channel = self.make_request( + "GET", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + request, channel = self.make_request( + "PUT", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + request, channel = self.make_request( + "DELETE", url, access_token=self.admin_user_tok, + ) + self.render(request) + + # Delete unknown device returns status 200 + self.assertEqual(200, channel.code, msg=channel.json_body) + + def test_update_device_too_long_display_name(self): + """ + Update a device with a display name that is invalid (too long). + """ + # Set iniital display name. + update = {"display_name": "new display"} + self.get_success( + self.handler.update_device( + self.other_user, self.other_user_device_id, update + ) + ) + + # Request to update a device display name with a new value that is longer than allowed. + update = { + "display_name": "a" + * (synapse.handlers.device.MAX_DEVICE_DISPLAY_NAME_LEN + 1) + } + + body = json.dumps(update) + request, channel = self.make_request( + "PUT", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"]) + + # Ensure the display name was not updated. + request, channel = self.make_request( + "GET", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual("new display", channel.json_body["display_name"]) + + def test_update_no_display_name(self): + """ + Tests that a update for a device without JSON returns a 200 + """ + # Set iniital display name. + update = {"display_name": "new display"} + self.get_success( + self.handler.update_device( + self.other_user, self.other_user_device_id, update + ) + ) + + request, channel = self.make_request( + "PUT", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Ensure the display name was not updated. + request, channel = self.make_request( + "GET", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual("new display", channel.json_body["display_name"]) + + def test_update_display_name(self): + """ + Tests a normal successful update of display name + """ + # Set new display_name + body = json.dumps({"display_name": "new displayname"}) + request, channel = self.make_request( + "PUT", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Check new display_name + request, channel = self.make_request( + "GET", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual("new displayname", channel.json_body["display_name"]) + + def test_get_device(self): + """ + Tests that a normal lookup for a device is successfully + """ + request, channel = self.make_request( + "GET", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(self.other_user, channel.json_body["user_id"]) + # Check that all fields are available + self.assertIn("user_id", channel.json_body) + self.assertIn("device_id", channel.json_body) + self.assertIn("display_name", channel.json_body) + self.assertIn("last_seen_ip", channel.json_body) + self.assertIn("last_seen_ts", channel.json_body) + + def test_delete_device(self): + """ + Tests that a remove of a device is successfully + """ + # Count number of devies of an user. + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + number_devices = len(res) + self.assertEqual(1, number_devices) + + # Delete device + request, channel = self.make_request( + "DELETE", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Ensure that the number of devices is decreased + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.assertEqual(number_devices - 1, len(res)) + + +class DevicesRestTestCase(unittest.HomeserverTestCase): + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + + self.url = "/_synapse/admin/v2/users/%s/devices" % urllib.parse.quote( + self.other_user + ) + + def test_no_auth(self): + """ + Try to list devices of an user without authentication. + """ + request, channel = self.make_request("GET", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self): + """ + If the user is not a server admin, an error is returned. + """ + other_user_token = self.login("user", "pass") + + request, channel = self.make_request( + "GET", self.url, access_token=other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_user_does_not_exist(self): + """ + Tests that a lookup for a user that does not exist returns a 404 + """ + url = "/_synapse/admin/v2/users/@unknown_person:test/devices" + request, channel = self.make_request( + "GET", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_user_is_not_local(self): + """ + Tests that a lookup for a user that is not a local returns a 400 + """ + url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices" + + request, channel = self.make_request( + "GET", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + def test_get_devices(self): + """ + Tests that a normal lookup for devices is successfully + """ + # Create devices + number_devices = 5 + for n in range(number_devices): + self.login("user", "pass") + + # Get devices + request, channel = self.make_request( + "GET", self.url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(number_devices, len(channel.json_body["devices"])) + self.assertEqual(self.other_user, channel.json_body["devices"][0]["user_id"]) + # Check that all fields are available + for d in channel.json_body["devices"]: + self.assertIn("user_id", d) + self.assertIn("device_id", d) + self.assertIn("display_name", d) + self.assertIn("last_seen_ip", d) + self.assertIn("last_seen_ts", d) + + +class DeleteDevicesRestTestCase(unittest.HomeserverTestCase): + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.handler = hs.get_device_handler() + + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + + self.url = "/_synapse/admin/v2/users/%s/delete_devices" % urllib.parse.quote( + self.other_user + ) + + def test_no_auth(self): + """ + Try to delete devices of an user without authentication. + """ + request, channel = self.make_request("POST", self.url, b"{}") + self.render(request) + + self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self): + """ + If the user is not a server admin, an error is returned. + """ + other_user_token = self.login("user", "pass") + + request, channel = self.make_request( + "POST", self.url, access_token=other_user_token, + ) + self.render(request) + + self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_user_does_not_exist(self): + """ + Tests that a lookup for a user that does not exist returns a 404 + """ + url = "/_synapse/admin/v2/users/@unknown_person:test/delete_devices" + request, channel = self.make_request( + "POST", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_user_is_not_local(self): + """ + Tests that a lookup for a user that is not a local returns a 400 + """ + url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/delete_devices" + + request, channel = self.make_request( + "POST", url, access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual("Can only lookup local users", channel.json_body["error"]) + + def test_unknown_devices(self): + """ + Tests that a remove of a device that does not exist returns 200. + """ + body = json.dumps({"devices": ["unknown_device1", "unknown_device2"]}) + request, channel = self.make_request( + "POST", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + # Delete unknown devices returns status 200 + self.assertEqual(200, channel.code, msg=channel.json_body) + + def test_delete_devices(self): + """ + Tests that a remove of devices is successfully + """ + + # Create devices + number_devices = 5 + for n in range(number_devices): + self.login("user", "pass") + + # Get devices + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.assertEqual(number_devices, len(res)) + + # Create list of device IDs + device_ids = [] + for d in res: + device_ids.append(str(d["device_id"])) + + # Delete devices + body = json.dumps({"devices": device_ids}) + request, channel = self.make_request( + "POST", + self.url, + access_token=self.admin_user_tok, + content=body.encode(encoding="utf_8"), + ) + self.render(request) + + self.assertEqual(200, channel.code, msg=channel.json_body) + + res = self.get_success(self.handler.get_devices_by_user(self.other_user)) + self.assertEqual(0, len(res)) diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index 360d8e4dfcac..6c88ab06e260 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -22,7 +22,6 @@ import synapse.rest.admin from synapse.api.constants import UserTypes -from synapse.api.errors import Codes from synapse.rest.client.v1 import login from tests import unittest @@ -723,521 +722,3 @@ def test_accidental_deactivation_prevention(self): # Ensure they're still alive self.assertEqual(0, channel.json_body["deactivated"]) - - -class DeviceRestTestCase(unittest.HomeserverTestCase): - - servlets = [ - synapse.rest.admin.register_servlets, - login.register_servlets, - ] - - def prepare(self, reactor, clock, hs): - self.handler = hs.get_device_handler() - - self.admin_user = self.register_user("admin", "pass", admin=True) - self.admin_user_tok = self.login("admin", "pass") - - self.other_user = self.register_user("user", "pass") - self.other_user_token = self.login("user", "pass") - res = self.get_success(self.handler.get_devices_by_user(self.other_user)) - self.other_user_device_id = res[0]["device_id"] - - self.url = "/_synapse/admin/v2/users/%s/devices/%s" % ( - urllib.parse.quote(self.other_user), - self.other_user_device_id, - ) - - def test_no_auth(self): - """ - Try to get a device of an user without authentication. - """ - request, channel = self.make_request("GET", self.url, b"{}") - self.render(request) - - self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) - - request, channel = self.make_request("PUT", self.url, b"{}") - self.render(request) - - self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) - - request, channel = self.make_request("DELETE", self.url, b"{}") - self.render(request) - - self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) - - def test_requester_is_no_admin(self): - """ - If the user is not a server admin, an error is returned. - """ - request, channel = self.make_request( - "GET", self.url, access_token=self.other_user_token, - ) - self.render(request) - - self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) - - request, channel = self.make_request( - "PUT", self.url, access_token=self.other_user_token, - ) - self.render(request) - - self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) - - request, channel = self.make_request( - "DELETE", self.url, access_token=self.other_user_token, - ) - self.render(request) - - self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) - - def test_user_does_not_exist(self): - """ - Tests that a lookup for a user that does not exist returns a 404 - """ - url = ( - "/_synapse/admin/v2/users/@unknown_person:test/devices/%s" - % self.other_user_device_id - ) - - request, channel = self.make_request( - "GET", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - request, channel = self.make_request( - "PUT", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - request, channel = self.make_request( - "DELETE", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - def test_user_is_not_local(self): - """ - Tests that a lookup for a user that is not a local returns a 400 - """ - url = ( - "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices/%s" - % self.other_user_device_id - ) - - request, channel = self.make_request( - "GET", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual("Can only lookup local users", channel.json_body["error"]) - - request, channel = self.make_request( - "PUT", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual("Can only lookup local users", channel.json_body["error"]) - - request, channel = self.make_request( - "DELETE", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual("Can only lookup local users", channel.json_body["error"]) - - def test_unknown_device(self): - """ - Tests that a lookup for a device that does not exist returns either 404 or 200. - """ - url = "/_synapse/admin/v2/users/%s/devices/unknown_device" % urllib.parse.quote( - self.other_user - ) - - request, channel = self.make_request( - "GET", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - request, channel = self.make_request( - "PUT", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - - request, channel = self.make_request( - "DELETE", url, access_token=self.admin_user_tok, - ) - self.render(request) - - # Delete unknown device returns status 200 - self.assertEqual(200, channel.code, msg=channel.json_body) - - def test_update_device_too_long_display_name(self): - """ - Update a device with a display name that is invalid (too long). - """ - # Set iniital display name. - update = {"display_name": "new display"} - self.get_success( - self.handler.update_device( - self.other_user, self.other_user_device_id, update - ) - ) - - # Request to update a device display name with a new value that is longer than allowed. - update = { - "display_name": "a" - * (synapse.handlers.device.MAX_DEVICE_DISPLAY_NAME_LEN + 1) - } - - body = json.dumps(update) - request, channel = self.make_request( - "PUT", - self.url, - access_token=self.admin_user_tok, - content=body.encode(encoding="utf_8"), - ) - self.render(request) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"]) - - # Ensure the display name was not updated. - request, channel = self.make_request( - "GET", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual("new display", channel.json_body["display_name"]) - - def test_update_no_display_name(self): - """ - Tests that a update for a device without JSON returns a 200 - """ - # Set iniital display name. - update = {"display_name": "new display"} - self.get_success( - self.handler.update_device( - self.other_user, self.other_user_device_id, update - ) - ) - - request, channel = self.make_request( - "PUT", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - - # Ensure the display name was not updated. - request, channel = self.make_request( - "GET", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual("new display", channel.json_body["display_name"]) - - def test_update_display_name(self): - """ - Tests a normal successful update of display name - """ - # Set new display_name - body = json.dumps({"display_name": "new displayname"}) - request, channel = self.make_request( - "PUT", - self.url, - access_token=self.admin_user_tok, - content=body.encode(encoding="utf_8"), - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - - # Check new display_name - request, channel = self.make_request( - "GET", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual("new displayname", channel.json_body["display_name"]) - - def test_get_device(self): - """ - Tests that a normal lookup for a device is successfully - """ - request, channel = self.make_request( - "GET", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(self.other_user, channel.json_body["user_id"]) - # Check that all fields are available - self.assertIn("user_id", channel.json_body) - self.assertIn("device_id", channel.json_body) - self.assertIn("display_name", channel.json_body) - self.assertIn("last_seen_ip", channel.json_body) - self.assertIn("last_seen_ts", channel.json_body) - - def test_delete_device(self): - """ - Tests that a remove of a device is successfully - """ - # Count number of devies of an user. - res = self.get_success(self.handler.get_devices_by_user(self.other_user)) - number_devices = len(res) - self.assertEqual(1, number_devices) - - # Delete device - request, channel = self.make_request( - "DELETE", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - - # Ensure that the number of devices is decreased - res = self.get_success(self.handler.get_devices_by_user(self.other_user)) - self.assertEqual(number_devices - 1, len(res)) - - -class DevicesRestTestCase(unittest.HomeserverTestCase): - - servlets = [ - synapse.rest.admin.register_servlets, - login.register_servlets, - ] - - def prepare(self, reactor, clock, hs): - self.admin_user = self.register_user("admin", "pass", admin=True) - self.admin_user_tok = self.login("admin", "pass") - - self.other_user = self.register_user("user", "pass") - - self.url = "/_synapse/admin/v2/users/%s/devices" % urllib.parse.quote( - self.other_user - ) - - def test_no_auth(self): - """ - Try to list devices of an user without authentication. - """ - request, channel = self.make_request("GET", self.url, b"{}") - self.render(request) - - self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) - - def test_requester_is_no_admin(self): - """ - If the user is not a server admin, an error is returned. - """ - other_user_token = self.login("user", "pass") - - request, channel = self.make_request( - "GET", self.url, access_token=other_user_token, - ) - self.render(request) - - self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) - - def test_user_does_not_exist(self): - """ - Tests that a lookup for a user that does not exist returns a 404 - """ - url = "/_synapse/admin/v2/users/@unknown_person:test/devices" - request, channel = self.make_request( - "GET", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - def test_user_is_not_local(self): - """ - Tests that a lookup for a user that is not a local returns a 400 - """ - url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/devices" - - request, channel = self.make_request( - "GET", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual("Can only lookup local users", channel.json_body["error"]) - - def test_get_devices(self): - """ - Tests that a normal lookup for devices is successfully - """ - # Create devices - number_devices = 5 - for n in range(number_devices): - self.login("user", "pass") - - # Get devices - request, channel = self.make_request( - "GET", self.url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(number_devices, len(channel.json_body["devices"])) - self.assertEqual(self.other_user, channel.json_body["devices"][0]["user_id"]) - # Check that all fields are available - for d in channel.json_body["devices"]: - self.assertIn("user_id", d) - self.assertIn("device_id", d) - self.assertIn("display_name", d) - self.assertIn("last_seen_ip", d) - self.assertIn("last_seen_ts", d) - - -class DeleteDevicesRestTestCase(unittest.HomeserverTestCase): - - servlets = [ - synapse.rest.admin.register_servlets, - login.register_servlets, - ] - - def prepare(self, reactor, clock, hs): - self.handler = hs.get_device_handler() - - self.admin_user = self.register_user("admin", "pass", admin=True) - self.admin_user_tok = self.login("admin", "pass") - - self.other_user = self.register_user("user", "pass") - - self.url = "/_synapse/admin/v2/users/%s/delete_devices" % urllib.parse.quote( - self.other_user - ) - - def test_no_auth(self): - """ - Try to delete devices of an user without authentication. - """ - request, channel = self.make_request("POST", self.url, b"{}") - self.render(request) - - self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) - - def test_requester_is_no_admin(self): - """ - If the user is not a server admin, an error is returned. - """ - other_user_token = self.login("user", "pass") - - request, channel = self.make_request( - "POST", self.url, access_token=other_user_token, - ) - self.render(request) - - self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"]) - self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) - - def test_user_does_not_exist(self): - """ - Tests that a lookup for a user that does not exist returns a 404 - """ - url = "/_synapse/admin/v2/users/@unknown_person:test/delete_devices" - request, channel = self.make_request( - "POST", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - def test_user_is_not_local(self): - """ - Tests that a lookup for a user that is not a local returns a 400 - """ - url = "/_synapse/admin/v2/users/@unknown_person:unknown_domain/delete_devices" - - request, channel = self.make_request( - "POST", url, access_token=self.admin_user_tok, - ) - self.render(request) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual("Can only lookup local users", channel.json_body["error"]) - - def test_unknown_devices(self): - """ - Tests that a remove of a device that does not exist returns 200. - """ - body = json.dumps({"devices": ["unknown_device1", "unknown_device2"]}) - request, channel = self.make_request( - "POST", - self.url, - access_token=self.admin_user_tok, - content=body.encode(encoding="utf_8"), - ) - self.render(request) - - # Delete unknown devices returns status 200 - self.assertEqual(200, channel.code, msg=channel.json_body) - - def test_delete_devices(self): - """ - Tests that a remove of devices is successfully - """ - - # Create devices - number_devices = 5 - for n in range(number_devices): - self.login("user", "pass") - - # Get devices - res = self.get_success(self.handler.get_devices_by_user(self.other_user)) - self.assertEqual(number_devices, len(res)) - - # Create list of device IDs - device_ids = [] - for d in res: - device_ids.append(str(d["device_id"])) - - # Delete devices - body = json.dumps({"devices": device_ids}) - request, channel = self.make_request( - "POST", - self.url, - access_token=self.admin_user_tok, - content=body.encode(encoding="utf_8"), - ) - self.render(request) - - self.assertEqual(200, channel.code, msg=channel.json_body) - - res = self.get_success(self.handler.get_devices_by_user(self.other_user)) - self.assertEqual(0, len(res)) From d95b27b39c170452a0d28e4105836e94bcf9af5f Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 5 Jun 2020 12:13:23 +0100 Subject: [PATCH 5/5] Apply suggestions from code review --- changelog.d/7481.feature | 2 +- docs/admin_api/user_admin_api.rst | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/changelog.d/7481.feature b/changelog.d/7481.feature index 875d94407773..f167f3632c14 100644 --- a/changelog.d/7481.feature +++ b/changelog.d/7481.feature @@ -1 +1 @@ -Add admin APIs to allow server admins to list users' devices, and log out specific devices. \ No newline at end of file +Add admin APIs to allow server admins to manage users' devices. Contributed by @dklimpel. diff --git a/docs/admin_api/user_admin_api.rst b/docs/admin_api/user_admin_api.rst index 9331f11c85e7..4f425e03205f 100644 --- a/docs/admin_api/user_admin_api.rst +++ b/docs/admin_api/user_admin_api.rst @@ -257,7 +257,7 @@ Gets information about all devices for a specific ``user_id``. **Usage** -A standard request for query devices of an user: +A standard request to query the devices of an user: :: @@ -297,7 +297,7 @@ The following query parameters are available: The following fields are possible in the JSON response body: - ``devices`` - An array of objects, each containing information about a device. - Devices objects contain the following fields: + Device objects contain the following fields: - ``device_id`` - Identifier of device. - ``display_name`` - Display name set by the user for this device. @@ -315,7 +315,7 @@ any access token associated with them. **Usage** -A standard request for delete devices: +A standard request to delete devices: :: @@ -351,7 +351,7 @@ Gets information on a single device, by ``device_id`` for a specific ``user_id`` **Usage** -A standard request for get a device: +A standard request to get a device: :: @@ -396,7 +396,7 @@ Updates the metadata on the given ``device_id`` for a specific ``user_id``. **Usage** -A standard request for update a device: +A standard request to update a device: ::