mirror of
https://mau.dev/maunium/synapse.git
synced 2024-10-01 01:36:05 -04:00
Add support for MSC3823 - Account Suspension Part 2 (#17255)
This commit is contained in:
parent
cf711ac03c
commit
7a11c0ac4f
1
changelog.d/17255.feature
Normal file
1
changelog.d/17255.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Add support for [MSC823](https://github.com/matrix-org/matrix-spec-proposals/pull/3823) - Account suspension.
|
@ -433,6 +433,10 @@ class ExperimentalConfig(Config):
|
|||||||
("experimental", "msc4108_delegation_endpoint"),
|
("experimental", "msc4108_delegation_endpoint"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.msc3823_account_suspension = experimental.get(
|
||||||
|
"msc3823_account_suspension", False
|
||||||
|
)
|
||||||
|
|
||||||
self.msc3916_authenticated_media_enabled = experimental.get(
|
self.msc3916_authenticated_media_enabled = experimental.get(
|
||||||
"msc3916_authenticated_media_enabled", False
|
"msc3916_authenticated_media_enabled", False
|
||||||
)
|
)
|
||||||
|
@ -642,6 +642,17 @@ class EventCreationHandler:
|
|||||||
"""
|
"""
|
||||||
await self.auth_blocking.check_auth_blocking(requester=requester)
|
await self.auth_blocking.check_auth_blocking(requester=requester)
|
||||||
|
|
||||||
|
if event_dict["type"] == EventTypes.Message:
|
||||||
|
requester_suspended = await self.store.get_user_suspended_status(
|
||||||
|
requester.user.to_string()
|
||||||
|
)
|
||||||
|
if requester_suspended:
|
||||||
|
raise SynapseError(
|
||||||
|
403,
|
||||||
|
"Sending messages while account is suspended is not allowed.",
|
||||||
|
Codes.USER_ACCOUNT_SUSPENDED,
|
||||||
|
)
|
||||||
|
|
||||||
if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "":
|
if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "":
|
||||||
room_version_id = event_dict["content"]["room_version"]
|
room_version_id = event_dict["content"]["room_version"]
|
||||||
maybe_room_version_obj = KNOWN_ROOM_VERSIONS.get(room_version_id)
|
maybe_room_version_obj = KNOWN_ROOM_VERSIONS.get(room_version_id)
|
||||||
|
@ -101,6 +101,7 @@ from synapse.rest.admin.users import (
|
|||||||
ResetPasswordRestServlet,
|
ResetPasswordRestServlet,
|
||||||
SearchUsersRestServlet,
|
SearchUsersRestServlet,
|
||||||
ShadowBanRestServlet,
|
ShadowBanRestServlet,
|
||||||
|
SuspendAccountRestServlet,
|
||||||
UserAdminServlet,
|
UserAdminServlet,
|
||||||
UserByExternalId,
|
UserByExternalId,
|
||||||
UserByThreePid,
|
UserByThreePid,
|
||||||
@ -327,6 +328,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
|
|||||||
BackgroundUpdateRestServlet(hs).register(http_server)
|
BackgroundUpdateRestServlet(hs).register(http_server)
|
||||||
BackgroundUpdateStartJobRestServlet(hs).register(http_server)
|
BackgroundUpdateStartJobRestServlet(hs).register(http_server)
|
||||||
ExperimentalFeaturesRestServlet(hs).register(http_server)
|
ExperimentalFeaturesRestServlet(hs).register(http_server)
|
||||||
|
if hs.config.experimental.msc3823_account_suspension:
|
||||||
|
SuspendAccountRestServlet(hs).register(http_server)
|
||||||
|
|
||||||
|
|
||||||
def register_servlets_for_client_rest_resource(
|
def register_servlets_for_client_rest_resource(
|
||||||
|
@ -27,11 +27,13 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
|
|||||||
|
|
||||||
import attr
|
import attr
|
||||||
|
|
||||||
|
from synapse._pydantic_compat import HAS_PYDANTIC_V2
|
||||||
from synapse.api.constants import Direction, UserTypes
|
from synapse.api.constants import Direction, UserTypes
|
||||||
from synapse.api.errors import Codes, NotFoundError, SynapseError
|
from synapse.api.errors import Codes, NotFoundError, SynapseError
|
||||||
from synapse.http.servlet import (
|
from synapse.http.servlet import (
|
||||||
RestServlet,
|
RestServlet,
|
||||||
assert_params_in_dict,
|
assert_params_in_dict,
|
||||||
|
parse_and_validate_json_object_from_request,
|
||||||
parse_boolean,
|
parse_boolean,
|
||||||
parse_enum,
|
parse_enum,
|
||||||
parse_integer,
|
parse_integer,
|
||||||
@ -49,10 +51,17 @@ from synapse.rest.client._base import client_patterns
|
|||||||
from synapse.storage.databases.main.registration import ExternalIDReuseException
|
from synapse.storage.databases.main.registration import ExternalIDReuseException
|
||||||
from synapse.storage.databases.main.stats import UserSortOrder
|
from synapse.storage.databases.main.stats import UserSortOrder
|
||||||
from synapse.types import JsonDict, JsonMapping, UserID
|
from synapse.types import JsonDict, JsonMapping, UserID
|
||||||
|
from synapse.types.rest import RequestBodyModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
|
|
||||||
|
if TYPE_CHECKING or HAS_PYDANTIC_V2:
|
||||||
|
from pydantic.v1 import StrictBool
|
||||||
|
else:
|
||||||
|
from pydantic import StrictBool
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -732,6 +741,36 @@ class DeactivateAccountRestServlet(RestServlet):
|
|||||||
return HTTPStatus.OK, {"id_server_unbind_result": id_server_unbind_result}
|
return HTTPStatus.OK, {"id_server_unbind_result": id_server_unbind_result}
|
||||||
|
|
||||||
|
|
||||||
|
class SuspendAccountRestServlet(RestServlet):
|
||||||
|
PATTERNS = admin_patterns("/suspend/(?P<target_user_id>[^/]*)$")
|
||||||
|
|
||||||
|
def __init__(self, hs: "HomeServer"):
|
||||||
|
self.auth = hs.get_auth()
|
||||||
|
self.is_mine = hs.is_mine
|
||||||
|
self.store = hs.get_datastores().main
|
||||||
|
|
||||||
|
class PutBody(RequestBodyModel):
|
||||||
|
suspend: StrictBool
|
||||||
|
|
||||||
|
async def on_PUT(
|
||||||
|
self, request: SynapseRequest, target_user_id: str
|
||||||
|
) -> Tuple[int, JsonDict]:
|
||||||
|
requester = await self.auth.get_user_by_req(request)
|
||||||
|
await assert_user_is_admin(self.auth, requester)
|
||||||
|
|
||||||
|
if not self.is_mine(UserID.from_string(target_user_id)):
|
||||||
|
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only suspend local users")
|
||||||
|
|
||||||
|
if not await self.store.get_user_by_id(target_user_id):
|
||||||
|
raise NotFoundError("User not found")
|
||||||
|
|
||||||
|
body = parse_and_validate_json_object_from_request(request, self.PutBody)
|
||||||
|
suspend = body.suspend
|
||||||
|
await self.store.set_user_suspended_status(target_user_id, suspend)
|
||||||
|
|
||||||
|
return HTTPStatus.OK, {f"user_{target_user_id}_suspended": suspend}
|
||||||
|
|
||||||
|
|
||||||
class AccountValidityRenewServlet(RestServlet):
|
class AccountValidityRenewServlet(RestServlet):
|
||||||
PATTERNS = admin_patterns("/account_validity/validity$")
|
PATTERNS = admin_patterns("/account_validity/validity$")
|
||||||
|
|
||||||
|
@ -108,6 +108,19 @@ class ProfileDisplaynameRestServlet(RestServlet):
|
|||||||
|
|
||||||
propagate = _read_propagate(self.hs, request)
|
propagate = _read_propagate(self.hs, request)
|
||||||
|
|
||||||
|
requester_suspended = (
|
||||||
|
await self.hs.get_datastores().main.get_user_suspended_status(
|
||||||
|
requester.user.to_string()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if requester_suspended:
|
||||||
|
raise SynapseError(
|
||||||
|
403,
|
||||||
|
"Updating displayname while account is suspended is not allowed.",
|
||||||
|
Codes.USER_ACCOUNT_SUSPENDED,
|
||||||
|
)
|
||||||
|
|
||||||
await self.profile_handler.set_displayname(
|
await self.profile_handler.set_displayname(
|
||||||
user, requester, new_name, is_admin, propagate=propagate
|
user, requester, new_name, is_admin, propagate=propagate
|
||||||
)
|
)
|
||||||
@ -167,6 +180,19 @@ class ProfileAvatarURLRestServlet(RestServlet):
|
|||||||
|
|
||||||
propagate = _read_propagate(self.hs, request)
|
propagate = _read_propagate(self.hs, request)
|
||||||
|
|
||||||
|
requester_suspended = (
|
||||||
|
await self.hs.get_datastores().main.get_user_suspended_status(
|
||||||
|
requester.user.to_string()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if requester_suspended:
|
||||||
|
raise SynapseError(
|
||||||
|
403,
|
||||||
|
"Updating avatar URL while account is suspended is not allowed.",
|
||||||
|
Codes.USER_ACCOUNT_SUSPENDED,
|
||||||
|
)
|
||||||
|
|
||||||
await self.profile_handler.set_avatar_url(
|
await self.profile_handler.set_avatar_url(
|
||||||
user, requester, new_avatar_url, is_admin, propagate=propagate
|
user, requester, new_avatar_url, is_admin, propagate=propagate
|
||||||
)
|
)
|
||||||
|
@ -1120,6 +1120,20 @@ class RoomRedactEventRestServlet(TransactionRestServlet):
|
|||||||
) -> Tuple[int, JsonDict]:
|
) -> Tuple[int, JsonDict]:
|
||||||
content = parse_json_object_from_request(request)
|
content = parse_json_object_from_request(request)
|
||||||
|
|
||||||
|
requester_suspended = await self._store.get_user_suspended_status(
|
||||||
|
requester.user.to_string()
|
||||||
|
)
|
||||||
|
|
||||||
|
if requester_suspended:
|
||||||
|
event = await self._store.get_event(event_id, allow_none=True)
|
||||||
|
if event:
|
||||||
|
if event.sender != requester.user.to_string():
|
||||||
|
raise SynapseError(
|
||||||
|
403,
|
||||||
|
"You can only redact your own events while account is suspended.",
|
||||||
|
Codes.USER_ACCOUNT_SUSPENDED,
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure the redacts property in the content matches the one provided in
|
# Ensure the redacts property in the content matches the one provided in
|
||||||
# the URL.
|
# the URL.
|
||||||
room_version = await self._store.get_room_version(room_id)
|
room_version = await self._store.get_room_version(room_id)
|
||||||
|
@ -37,6 +37,7 @@ from synapse.api.constants import ApprovalNoticeMedium, LoginType, UserTypes
|
|||||||
from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError
|
from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError
|
||||||
from synapse.api.room_versions import RoomVersions
|
from synapse.api.room_versions import RoomVersions
|
||||||
from synapse.media.filepath import MediaFilePaths
|
from synapse.media.filepath import MediaFilePaths
|
||||||
|
from synapse.rest import admin
|
||||||
from synapse.rest.client import (
|
from synapse.rest.client import (
|
||||||
devices,
|
devices,
|
||||||
login,
|
login,
|
||||||
@ -5005,3 +5006,86 @@ class AllowCrossSigningReplacementTestCase(unittest.HomeserverTestCase):
|
|||||||
)
|
)
|
||||||
assert timestamp is not None
|
assert timestamp is not None
|
||||||
self.assertGreater(timestamp, self.clock.time_msec())
|
self.assertGreater(timestamp, self.clock.time_msec())
|
||||||
|
|
||||||
|
|
||||||
|
class UserSuspensionTestCase(unittest.HomeserverTestCase):
|
||||||
|
servlets = [
|
||||||
|
synapse.rest.admin.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
admin.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.admin = self.register_user("thomas", "hackme", True)
|
||||||
|
self.admin_tok = self.login("thomas", "hackme")
|
||||||
|
|
||||||
|
self.bad_user = self.register_user("teresa", "hackme")
|
||||||
|
self.bad_user_tok = self.login("teresa", "hackme")
|
||||||
|
|
||||||
|
self.store = hs.get_datastores().main
|
||||||
|
|
||||||
|
@override_config({"experimental_features": {"msc3823_account_suspension": True}})
|
||||||
|
def test_suspend_user(self) -> None:
|
||||||
|
# test that suspending user works
|
||||||
|
channel = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_synapse/admin/v1/suspend/{self.bad_user}",
|
||||||
|
{"suspend": True},
|
||||||
|
access_token=self.admin_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel.code, 200)
|
||||||
|
self.assertEqual(channel.json_body, {f"user_{self.bad_user}_suspended": True})
|
||||||
|
|
||||||
|
res = self.get_success(self.store.get_user_suspended_status(self.bad_user))
|
||||||
|
self.assertEqual(True, res)
|
||||||
|
|
||||||
|
# test that un-suspending user works
|
||||||
|
channel2 = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_synapse/admin/v1/suspend/{self.bad_user}",
|
||||||
|
{"suspend": False},
|
||||||
|
access_token=self.admin_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel2.code, 200)
|
||||||
|
self.assertEqual(channel2.json_body, {f"user_{self.bad_user}_suspended": False})
|
||||||
|
|
||||||
|
res2 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
|
||||||
|
self.assertEqual(False, res2)
|
||||||
|
|
||||||
|
# test that trying to un-suspend user who isn't suspended doesn't cause problems
|
||||||
|
channel3 = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_synapse/admin/v1/suspend/{self.bad_user}",
|
||||||
|
{"suspend": False},
|
||||||
|
access_token=self.admin_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel3.code, 200)
|
||||||
|
self.assertEqual(channel3.json_body, {f"user_{self.bad_user}_suspended": False})
|
||||||
|
|
||||||
|
res3 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
|
||||||
|
self.assertEqual(False, res3)
|
||||||
|
|
||||||
|
# test that trying to suspend user who is already suspended doesn't cause problems
|
||||||
|
channel4 = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_synapse/admin/v1/suspend/{self.bad_user}",
|
||||||
|
{"suspend": True},
|
||||||
|
access_token=self.admin_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel4.code, 200)
|
||||||
|
self.assertEqual(channel4.json_body, {f"user_{self.bad_user}_suspended": True})
|
||||||
|
|
||||||
|
res4 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
|
||||||
|
self.assertEqual(True, res4)
|
||||||
|
|
||||||
|
channel5 = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_synapse/admin/v1/suspend/{self.bad_user}",
|
||||||
|
{"suspend": True},
|
||||||
|
access_token=self.admin_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel5.code, 200)
|
||||||
|
self.assertEqual(channel5.json_body, {f"user_{self.bad_user}_suspended": True})
|
||||||
|
|
||||||
|
res5 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
|
||||||
|
self.assertEqual(True, res5)
|
||||||
|
@ -3819,3 +3819,108 @@ class TimestampLookupTestCase(unittest.HomeserverTestCase):
|
|||||||
|
|
||||||
# Make sure the outlier event is not returned
|
# Make sure the outlier event is not returned
|
||||||
self.assertNotEqual(channel.json_body["event_id"], outlier_event.event_id)
|
self.assertNotEqual(channel.json_body["event_id"], outlier_event.event_id)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSuspensionTests(unittest.HomeserverTestCase):
|
||||||
|
servlets = [
|
||||||
|
admin.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
room.register_servlets,
|
||||||
|
profile.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.user1 = self.register_user("thomas", "hackme")
|
||||||
|
self.tok1 = self.login("thomas", "hackme")
|
||||||
|
|
||||||
|
self.user2 = self.register_user("teresa", "hackme")
|
||||||
|
self.tok2 = self.login("teresa", "hackme")
|
||||||
|
|
||||||
|
self.room1 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
|
||||||
|
self.store = hs.get_datastores().main
|
||||||
|
|
||||||
|
def test_suspended_user_cannot_send_message_to_room(self) -> None:
|
||||||
|
# set the user as suspended
|
||||||
|
self.get_success(self.store.set_user_suspended_status(self.user1, True))
|
||||||
|
|
||||||
|
channel = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/rooms/{self.room1}/send/m.room.message/1",
|
||||||
|
access_token=self.tok1,
|
||||||
|
content={"body": "hello", "msgtype": "m.text"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_suspended_user_cannot_change_profile_data(self) -> None:
|
||||||
|
# set the user as suspended
|
||||||
|
self.get_success(self.store.set_user_suspended_status(self.user1, True))
|
||||||
|
|
||||||
|
channel = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_matrix/client/v3/profile/{self.user1}/avatar_url",
|
||||||
|
access_token=self.tok1,
|
||||||
|
content={"avatar_url": "mxc://matrix.org/wefh34uihSDRGhw34"},
|
||||||
|
shorthand=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
channel2 = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_matrix/client/v3/profile/{self.user1}/displayname",
|
||||||
|
access_token=self.tok1,
|
||||||
|
content={"displayname": "something offensive"},
|
||||||
|
shorthand=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
channel2.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_suspended_user_cannot_redact_messages_other_than_their_own(self) -> None:
|
||||||
|
# first user sends message
|
||||||
|
self.make_request("POST", f"/rooms/{self.room1}/join", access_token=self.tok2)
|
||||||
|
res = self.helper.send_event(
|
||||||
|
self.room1,
|
||||||
|
"m.room.message",
|
||||||
|
{"body": "hello", "msgtype": "m.text"},
|
||||||
|
tok=self.tok2,
|
||||||
|
)
|
||||||
|
event_id = res["event_id"]
|
||||||
|
|
||||||
|
# second user sends message
|
||||||
|
self.make_request("POST", f"/rooms/{self.room1}/join", access_token=self.tok1)
|
||||||
|
res2 = self.helper.send_event(
|
||||||
|
self.room1,
|
||||||
|
"m.room.message",
|
||||||
|
{"body": "bad_message", "msgtype": "m.text"},
|
||||||
|
tok=self.tok1,
|
||||||
|
)
|
||||||
|
event_id2 = res2["event_id"]
|
||||||
|
|
||||||
|
# set the second user as suspended
|
||||||
|
self.get_success(self.store.set_user_suspended_status(self.user1, True))
|
||||||
|
|
||||||
|
# second user can't redact first user's message
|
||||||
|
channel = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_matrix/client/v3/rooms/{self.room1}/redact/{event_id}/1",
|
||||||
|
access_token=self.tok1,
|
||||||
|
content={"reason": "bogus"},
|
||||||
|
shorthand=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
# but can redact their own
|
||||||
|
channel = self.make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/_matrix/client/v3/rooms/{self.room1}/redact/{event_id2}/1",
|
||||||
|
access_token=self.tok1,
|
||||||
|
content={"reason": "bogus"},
|
||||||
|
shorthand=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel.code, 200)
|
||||||
|
Loading…
Reference in New Issue
Block a user