mirror of
https://git.anonymousland.org/anonymousland/synapse.git
synced 2025-02-17 16:34:06 -05:00
add options to require an access_token to GET /profile and /publicRooms on CS API (#5083)
This commit adds two config options: * `restrict_public_rooms_to_local_users` Requires auth to fetch the public rooms directory through the CS API and disables fetching it through the federation API. * `require_auth_for_profile_requests` When set to `true`, requires that requests to `/profile` over the CS API are authenticated, and only returns the user's profile if the requester shares a room with the profile's owner, as per MSC1301. MSC1301 also specifies a behaviour for federation (only returning the profile if the server asking for it shares a room with the profile's owner), but that's currently really non-trivial to do in a not too expensive way. Next step is writing down a MSC that allows a HS to specify which user sent the profile query. In this implementation, Synapse won't send a profile query over federation if it doesn't believe it already shares a room with the profile's owner, though. Groups have been intentionally omitted from this commit.
This commit is contained in:
parent
c8c069db92
commit
c0e0740bef
1
changelog.d/5083.feature
Normal file
1
changelog.d/5083.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Add an configuration option to require authentication on /publicRooms and /profile endpoints.
|
@ -69,6 +69,20 @@ pid_file: DATADIR/homeserver.pid
|
|||||||
#
|
#
|
||||||
#use_presence: false
|
#use_presence: false
|
||||||
|
|
||||||
|
# Whether to require authentication to retrieve profile data (avatars,
|
||||||
|
# display names) of other users through the client API. Defaults to
|
||||||
|
# 'false'. Note that profile data is also available via the federation
|
||||||
|
# API, so this setting is of limited value if federation is enabled on
|
||||||
|
# the server.
|
||||||
|
#
|
||||||
|
#require_auth_for_profile_requests: true
|
||||||
|
|
||||||
|
# If set to 'true', requires authentication to access the server's
|
||||||
|
# public rooms directory through the client API, and forbids any other
|
||||||
|
# homeserver to fetch it via federation. Defaults to 'false'.
|
||||||
|
#
|
||||||
|
#restrict_public_rooms_to_local_users: true
|
||||||
|
|
||||||
# The GC threshold parameters to pass to `gc.set_threshold`, if defined
|
# The GC threshold parameters to pass to `gc.set_threshold`, if defined
|
||||||
#
|
#
|
||||||
#gc_thresholds: [700, 10, 10]
|
#gc_thresholds: [700, 10, 10]
|
||||||
|
@ -72,6 +72,19 @@ class ServerConfig(Config):
|
|||||||
# master, potentially causing inconsistency.
|
# master, potentially causing inconsistency.
|
||||||
self.enable_media_repo = config.get("enable_media_repo", True)
|
self.enable_media_repo = config.get("enable_media_repo", True)
|
||||||
|
|
||||||
|
# Whether to require authentication to retrieve profile data (avatars,
|
||||||
|
# display names) of other users through the client API.
|
||||||
|
self.require_auth_for_profile_requests = config.get(
|
||||||
|
"require_auth_for_profile_requests", False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If set to 'True', requires authentication to access the server's
|
||||||
|
# public rooms directory through the client API, and forbids any other
|
||||||
|
# homeserver to fetch it via federation.
|
||||||
|
self.restrict_public_rooms_to_local_users = config.get(
|
||||||
|
"restrict_public_rooms_to_local_users", False,
|
||||||
|
)
|
||||||
|
|
||||||
# whether to enable search. If disabled, new entries will not be inserted
|
# whether to enable search. If disabled, new entries will not be inserted
|
||||||
# into the search tables and they will not be indexed. Users will receive
|
# into the search tables and they will not be indexed. Users will receive
|
||||||
# errors when attempting to search for messages.
|
# errors when attempting to search for messages.
|
||||||
@ -327,6 +340,20 @@ class ServerConfig(Config):
|
|||||||
#
|
#
|
||||||
#use_presence: false
|
#use_presence: false
|
||||||
|
|
||||||
|
# Whether to require authentication to retrieve profile data (avatars,
|
||||||
|
# display names) of other users through the client API. Defaults to
|
||||||
|
# 'false'. Note that profile data is also available via the federation
|
||||||
|
# API, so this setting is of limited value if federation is enabled on
|
||||||
|
# the server.
|
||||||
|
#
|
||||||
|
#require_auth_for_profile_requests: true
|
||||||
|
|
||||||
|
# If set to 'true', requires authentication to access the server's
|
||||||
|
# public rooms directory through the client API, and forbids any other
|
||||||
|
# homeserver to fetch it via federation. Defaults to 'false'.
|
||||||
|
#
|
||||||
|
#restrict_public_rooms_to_local_users: true
|
||||||
|
|
||||||
# The GC threshold parameters to pass to `gc.set_threshold`, if defined
|
# The GC threshold parameters to pass to `gc.set_threshold`, if defined
|
||||||
#
|
#
|
||||||
#gc_thresholds: [700, 10, 10]
|
#gc_thresholds: [700, 10, 10]
|
||||||
|
@ -716,8 +716,17 @@ class PublicRoomList(BaseFederationServlet):
|
|||||||
|
|
||||||
PATH = "/publicRooms"
|
PATH = "/publicRooms"
|
||||||
|
|
||||||
|
def __init__(self, handler, authenticator, ratelimiter, server_name, deny_access):
|
||||||
|
super(PublicRoomList, self).__init__(
|
||||||
|
handler, authenticator, ratelimiter, server_name,
|
||||||
|
)
|
||||||
|
self.deny_access = deny_access
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def on_GET(self, origin, content, query):
|
def on_GET(self, origin, content, query):
|
||||||
|
if self.deny_access:
|
||||||
|
raise FederationDeniedError(origin)
|
||||||
|
|
||||||
limit = parse_integer_from_args(query, "limit", 0)
|
limit = parse_integer_from_args(query, "limit", 0)
|
||||||
since_token = parse_string_from_args(query, "since", None)
|
since_token = parse_string_from_args(query, "since", None)
|
||||||
include_all_networks = parse_boolean_from_args(
|
include_all_networks = parse_boolean_from_args(
|
||||||
@ -1417,6 +1426,7 @@ def register_servlets(hs, resource, authenticator, ratelimiter, servlet_groups=N
|
|||||||
authenticator=authenticator,
|
authenticator=authenticator,
|
||||||
ratelimiter=ratelimiter,
|
ratelimiter=ratelimiter,
|
||||||
server_name=hs.hostname,
|
server_name=hs.hostname,
|
||||||
|
deny_access=hs.config.restrict_public_rooms_to_local_users,
|
||||||
).register(resource)
|
).register(resource)
|
||||||
|
|
||||||
if "group_server" in servlet_groups:
|
if "group_server" in servlet_groups:
|
||||||
|
@ -53,6 +53,7 @@ class BaseProfileHandler(BaseHandler):
|
|||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def get_profile(self, user_id):
|
def get_profile(self, user_id):
|
||||||
target_user = UserID.from_string(user_id)
|
target_user = UserID.from_string(user_id)
|
||||||
|
|
||||||
if self.hs.is_mine(target_user):
|
if self.hs.is_mine(target_user):
|
||||||
try:
|
try:
|
||||||
displayname = yield self.store.get_profile_displayname(
|
displayname = yield self.store.get_profile_displayname(
|
||||||
@ -283,6 +284,48 @@ class BaseProfileHandler(BaseHandler):
|
|||||||
room_id, str(e)
|
room_id, str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def check_profile_query_allowed(self, target_user, requester=None):
|
||||||
|
"""Checks whether a profile query is allowed. If the
|
||||||
|
'require_auth_for_profile_requests' config flag is set to True and a
|
||||||
|
'requester' is provided, the query is only allowed if the two users
|
||||||
|
share a room.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_user (UserID): The owner of the queried profile.
|
||||||
|
requester (None|UserID): The user querying for the profile.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SynapseError(403): The two users share no room, or ne user couldn't
|
||||||
|
be found to be in any room the server is in, and therefore the query
|
||||||
|
is denied.
|
||||||
|
"""
|
||||||
|
# Implementation of MSC1301: don't allow looking up profiles if the
|
||||||
|
# requester isn't in the same room as the target. We expect requester to
|
||||||
|
# be None when this function is called outside of a profile query, e.g.
|
||||||
|
# when building a membership event. In this case, we must allow the
|
||||||
|
# lookup.
|
||||||
|
if not self.hs.config.require_auth_for_profile_requests or not requester:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
requester_rooms = yield self.store.get_rooms_for_user(
|
||||||
|
requester.to_string()
|
||||||
|
)
|
||||||
|
target_user_rooms = yield self.store.get_rooms_for_user(
|
||||||
|
target_user.to_string(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the room lists have no elements in common.
|
||||||
|
if requester_rooms.isdisjoint(target_user_rooms):
|
||||||
|
raise SynapseError(403, "Profile isn't available", Codes.FORBIDDEN)
|
||||||
|
except StoreError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
# This likely means that one of the users doesn't exist,
|
||||||
|
# so we act as if we couldn't find the profile.
|
||||||
|
raise SynapseError(403, "Profile isn't available", Codes.FORBIDDEN)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class MasterProfileHandler(BaseProfileHandler):
|
class MasterProfileHandler(BaseProfileHandler):
|
||||||
PROFILE_UPDATE_MS = 60 * 1000
|
PROFILE_UPDATE_MS = 60 * 1000
|
||||||
|
@ -31,11 +31,17 @@ class ProfileDisplaynameRestServlet(ClientV1RestServlet):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def on_GET(self, request, user_id):
|
def on_GET(self, request, user_id):
|
||||||
|
requester_user = None
|
||||||
|
|
||||||
|
if self.hs.config.require_auth_for_profile_requests:
|
||||||
|
requester = yield self.auth.get_user_by_req(request)
|
||||||
|
requester_user = requester.user
|
||||||
|
|
||||||
user = UserID.from_string(user_id)
|
user = UserID.from_string(user_id)
|
||||||
|
|
||||||
displayname = yield self.profile_handler.get_displayname(
|
yield self.profile_handler.check_profile_query_allowed(user, requester_user)
|
||||||
user,
|
|
||||||
)
|
displayname = yield self.profile_handler.get_displayname(user)
|
||||||
|
|
||||||
ret = {}
|
ret = {}
|
||||||
if displayname is not None:
|
if displayname is not None:
|
||||||
@ -74,11 +80,17 @@ class ProfileAvatarURLRestServlet(ClientV1RestServlet):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def on_GET(self, request, user_id):
|
def on_GET(self, request, user_id):
|
||||||
|
requester_user = None
|
||||||
|
|
||||||
|
if self.hs.config.require_auth_for_profile_requests:
|
||||||
|
requester = yield self.auth.get_user_by_req(request)
|
||||||
|
requester_user = requester.user
|
||||||
|
|
||||||
user = UserID.from_string(user_id)
|
user = UserID.from_string(user_id)
|
||||||
|
|
||||||
avatar_url = yield self.profile_handler.get_avatar_url(
|
yield self.profile_handler.check_profile_query_allowed(user, requester_user)
|
||||||
user,
|
|
||||||
)
|
avatar_url = yield self.profile_handler.get_avatar_url(user)
|
||||||
|
|
||||||
ret = {}
|
ret = {}
|
||||||
if avatar_url is not None:
|
if avatar_url is not None:
|
||||||
@ -116,14 +128,18 @@ class ProfileRestServlet(ClientV1RestServlet):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def on_GET(self, request, user_id):
|
def on_GET(self, request, user_id):
|
||||||
|
requester_user = None
|
||||||
|
|
||||||
|
if self.hs.config.require_auth_for_profile_requests:
|
||||||
|
requester = yield self.auth.get_user_by_req(request)
|
||||||
|
requester_user = requester.user
|
||||||
|
|
||||||
user = UserID.from_string(user_id)
|
user = UserID.from_string(user_id)
|
||||||
|
|
||||||
displayname = yield self.profile_handler.get_displayname(
|
yield self.profile_handler.check_profile_query_allowed(user, requester_user)
|
||||||
user,
|
|
||||||
)
|
displayname = yield self.profile_handler.get_displayname(user)
|
||||||
avatar_url = yield self.profile_handler.get_avatar_url(
|
avatar_url = yield self.profile_handler.get_avatar_url(user)
|
||||||
user,
|
|
||||||
)
|
|
||||||
|
|
||||||
ret = {}
|
ret = {}
|
||||||
if displayname is not None:
|
if displayname is not None:
|
||||||
|
@ -301,6 +301,12 @@ class PublicRoomListRestServlet(ClientV1RestServlet):
|
|||||||
try:
|
try:
|
||||||
yield self.auth.get_user_by_req(request, allow_guest=True)
|
yield self.auth.get_user_by_req(request, allow_guest=True)
|
||||||
except AuthError as e:
|
except AuthError as e:
|
||||||
|
# Option to allow servers to require auth when accessing
|
||||||
|
# /publicRooms via CS API. This is especially helpful in private
|
||||||
|
# federations.
|
||||||
|
if self.hs.config.restrict_public_rooms_to_local_users:
|
||||||
|
raise
|
||||||
|
|
||||||
# We allow people to not be authed if they're just looking at our
|
# We allow people to not be authed if they're just looking at our
|
||||||
# room list, but require auth when we proxy the request.
|
# room list, but require auth when we proxy the request.
|
||||||
# In both cases we call the auth function, as that has the side
|
# In both cases we call the auth function, as that has the side
|
||||||
|
@ -20,7 +20,7 @@ from twisted.internet import defer
|
|||||||
|
|
||||||
import synapse.types
|
import synapse.types
|
||||||
from synapse.api.errors import AuthError, SynapseError
|
from synapse.api.errors import AuthError, SynapseError
|
||||||
from synapse.rest.client.v1 import profile
|
from synapse.rest.client.v1 import admin, login, profile, room
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
|
|
||||||
@ -42,6 +42,7 @@ class ProfileTestCase(unittest.TestCase):
|
|||||||
"set_displayname",
|
"set_displayname",
|
||||||
"get_avatar_url",
|
"get_avatar_url",
|
||||||
"set_avatar_url",
|
"set_avatar_url",
|
||||||
|
"check_profile_query_allowed",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -155,3 +156,92 @@ class ProfileTestCase(unittest.TestCase):
|
|||||||
self.assertEquals(mocked_set.call_args[0][0].localpart, "1234ABCD")
|
self.assertEquals(mocked_set.call_args[0][0].localpart, "1234ABCD")
|
||||||
self.assertEquals(mocked_set.call_args[0][1].user.localpart, "1234ABCD")
|
self.assertEquals(mocked_set.call_args[0][1].user.localpart, "1234ABCD")
|
||||||
self.assertEquals(mocked_set.call_args[0][2], "http://my.server/pic.gif")
|
self.assertEquals(mocked_set.call_args[0][2], "http://my.server/pic.gif")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfilesRestrictedTestCase(unittest.HomeserverTestCase):
|
||||||
|
|
||||||
|
servlets = [
|
||||||
|
admin.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
profile.register_servlets,
|
||||||
|
room.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def make_homeserver(self, reactor, clock):
|
||||||
|
|
||||||
|
config = self.default_config()
|
||||||
|
config.require_auth_for_profile_requests = True
|
||||||
|
self.hs = self.setup_test_homeserver(config=config)
|
||||||
|
|
||||||
|
return self.hs
|
||||||
|
|
||||||
|
def prepare(self, reactor, clock, hs):
|
||||||
|
# User owning the requested profile.
|
||||||
|
self.owner = self.register_user("owner", "pass")
|
||||||
|
self.owner_tok = self.login("owner", "pass")
|
||||||
|
self.profile_url = "/profile/%s" % (self.owner)
|
||||||
|
|
||||||
|
# User requesting the profile.
|
||||||
|
self.requester = self.register_user("requester", "pass")
|
||||||
|
self.requester_tok = self.login("requester", "pass")
|
||||||
|
|
||||||
|
self.room_id = self.helper.create_room_as(self.owner, tok=self.owner_tok)
|
||||||
|
|
||||||
|
def test_no_auth(self):
|
||||||
|
self.try_fetch_profile(401)
|
||||||
|
|
||||||
|
def test_not_in_shared_room(self):
|
||||||
|
self.ensure_requester_left_room()
|
||||||
|
|
||||||
|
self.try_fetch_profile(403, access_token=self.requester_tok)
|
||||||
|
|
||||||
|
def test_in_shared_room(self):
|
||||||
|
self.ensure_requester_left_room()
|
||||||
|
|
||||||
|
self.helper.join(
|
||||||
|
room=self.room_id,
|
||||||
|
user=self.requester,
|
||||||
|
tok=self.requester_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.try_fetch_profile(200, self.requester_tok)
|
||||||
|
|
||||||
|
def try_fetch_profile(self, expected_code, access_token=None):
|
||||||
|
self.request_profile(
|
||||||
|
expected_code,
|
||||||
|
access_token=access_token
|
||||||
|
)
|
||||||
|
|
||||||
|
self.request_profile(
|
||||||
|
expected_code,
|
||||||
|
url_suffix="/displayname",
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.request_profile(
|
||||||
|
expected_code,
|
||||||
|
url_suffix="/avatar_url",
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def request_profile(self, expected_code, url_suffix="", access_token=None):
|
||||||
|
request, channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
self.profile_url + url_suffix,
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
self.render(request)
|
||||||
|
self.assertEqual(channel.code, expected_code, channel.result)
|
||||||
|
|
||||||
|
def ensure_requester_left_room(self):
|
||||||
|
try:
|
||||||
|
self.helper.leave(
|
||||||
|
room=self.room_id,
|
||||||
|
user=self.requester,
|
||||||
|
tok=self.requester_tok,
|
||||||
|
)
|
||||||
|
except AssertionError:
|
||||||
|
# We don't care whether the leave request didn't return a 200 (e.g.
|
||||||
|
# if the user isn't already in the room), because we only want to
|
||||||
|
# make sure the user isn't in the room.
|
||||||
|
pass
|
||||||
|
@ -904,3 +904,35 @@ class RoomSearchTestCase(unittest.HomeserverTestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
context["profile_info"][self.other_user_id]["displayname"], "otheruser"
|
context["profile_info"][self.other_user_id]["displayname"], "otheruser"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicRoomsRestrictedTestCase(unittest.HomeserverTestCase):
|
||||||
|
|
||||||
|
servlets = [
|
||||||
|
admin.register_servlets,
|
||||||
|
room.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def make_homeserver(self, reactor, clock):
|
||||||
|
|
||||||
|
self.url = b"/_matrix/client/r0/publicRooms"
|
||||||
|
|
||||||
|
config = self.default_config()
|
||||||
|
config.restrict_public_rooms_to_local_users = True
|
||||||
|
self.hs = self.setup_test_homeserver(config=config)
|
||||||
|
|
||||||
|
return self.hs
|
||||||
|
|
||||||
|
def test_restricted_no_auth(self):
|
||||||
|
request, channel = self.make_request("GET", self.url)
|
||||||
|
self.render(request)
|
||||||
|
self.assertEqual(channel.code, 401, channel.result)
|
||||||
|
|
||||||
|
def test_restricted_auth(self):
|
||||||
|
self.register_user("user", "pass")
|
||||||
|
tok = self.login("user", "pass")
|
||||||
|
|
||||||
|
request, channel = self.make_request("GET", self.url, access_token=tok)
|
||||||
|
self.render(request)
|
||||||
|
self.assertEqual(channel.code, 200, channel.result)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user