Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Add admin API for logging in as a user #8617

Merged
merged 12 commits into from
Nov 17, 2020
1 change: 1 addition & 0 deletions changelog.d/8617.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add admin API for logging in as a user.
35 changes: 35 additions & 0 deletions docs/admin_api/user_admin_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,41 @@ The following fields are returned in the JSON response body:
- ``next_token``: integer - Indication for pagination. See above.
- ``total`` - integer - Total number of media.

Login as a user
===============

Get an access token that can be used to authenticate as that user. Useful for
when admins wish to do actions on behalf of a user.

The API is::

PUT /_synapse/admin/v1/users/<user_id>/login
{}

An optional ``valid_until_ms`` field can be specified in the request body as an
integer timestamp that specifies when the token should expire. By default tokens
do not expire.

A response body like the following is returned:

.. code:: json

{
"access_token": "<opaque_access_token_string>"
}


This API does *not* generate a new device for the user, and so will not appear
their ``/devices`` list, and in general the target user should not be able to
tell they have been logged in as.

To expire the token call the standard ``/logout`` API with the token.

Note: The token will expire if the *admin* user calls ``/logout/all`` from any
of their devices, but the token will *not* expire if the target user does the
same.


User devices
============

Expand Down
27 changes: 23 additions & 4 deletions synapse/api/auth_blocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
# limitations under the License.

import logging
from typing import Optional

from synapse.api.constants import LimitBlockingTypes, UserTypes
from synapse.api.errors import Codes, ResourceLimitError
from synapse.config.server import is_threepid_reserved
from synapse.types import Requester

logger = logging.getLogger(__name__)

Expand All @@ -33,24 +35,41 @@ def __init__(self, hs):
self._max_mau_value = hs.config.max_mau_value
self._limit_usage_by_mau = hs.config.limit_usage_by_mau
self._mau_limits_reserved_threepids = hs.config.mau_limits_reserved_threepids
self._server_name = hs.hostname

async def check_auth_blocking(self, user_id=None, threepid=None, user_type=None):
async def check_auth_blocking(
self,
user_id: Optional[str] = None,
threepid: Optional[dict] = None,
user_type: Optional[str] = None,
requester: Optional[Requester] = None,
):
"""Checks if the user should be rejected for some external reason,
such as monthly active user limiting or global disable flag

Args:
user_id(str|None): If present, checks for presence against existing
user_id: If present, checks for presence against existing
MAU cohort

threepid(dict|None): If present, checks for presence against configured
threepid: If present, checks for presence against configured
reserved threepid. Used in cases where the user is trying register
with a MAU blocked server, normally they would be rejected but their
threepid is on the reserved list. user_id and
threepid should never be set at the same time.

user_type(str|None): If present, is used to decide whether to check against
user_type: If present, is used to decide whether to check against
certain blocking reasons like MAU.

requester: If present, and the authenticated entity is a user, checks for
presence against existing MAU cohort.
erikjohnston marked this conversation as resolved.
Show resolved Hide resolved
"""
if requester:
if requester.authenticated_entity.startswith("@"):
user_id = requester.authenticated_entity
elif requester.authenticated_entity == self._server_name:
# We never block the server from doing actions on behalf of
# users.
return

# Never fail an auth check for the server notices users or support user
# This can be a problem where event creation is prohibited due to blocking
Expand Down
4 changes: 3 additions & 1 deletion synapse/handlers/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ async def kick_guest_users(self, current_state):
# and having homeservers have their own users leave keeps more
# of that decision-making and control local to the guest-having
# homeserver.
requester = synapse.types.create_requester(target_user, is_guest=True)
requester = synapse.types.create_requester(
target_user, is_guest=True, authenticated_entity=self.server_name
)
handler = self.hs.get_room_member_handler()
await handler.update_membership(
requester,
Expand Down
24 changes: 20 additions & 4 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,8 +693,12 @@ def _auth_dict_for_flows(
}

async def get_access_token_for_user_id(
self, user_id: str, device_id: Optional[str], valid_until_ms: Optional[int]
):
self,
user_id: str,
device_id: Optional[str],
valid_until_ms: Optional[int],
puppets_user_id: Optional[str] = None,
) -> str:
"""
Creates a new access token for the user with the given user ID.

Expand All @@ -720,13 +724,25 @@ async def get_access_token_for_user_id(
fmt_expiry = time.strftime(
" until %Y-%m-%d %H:%M:%S", time.localtime(valid_until_ms / 1000.0)
)
logger.info("Logging in user %s on device %s%s", user_id, device_id, fmt_expiry)

if puppets_user_id:
logger.info(
"Logging in user %s as %s%s", user_id, puppets_user_id, fmt_expiry
)
else:
logger.info(
"Logging in user %s on device %s%s", user_id, device_id, fmt_expiry
)

await self.auth.check_auth_blocking(user_id)

access_token = self.macaroon_gen.generate_access_token(user_id)
await self.store.add_access_token_to_user(
user_id, access_token, device_id, valid_until_ms
user_id=user_id,
token=access_token,
device_id=device_id,
valid_until_ms=valid_until_ms,
puppets_user_id=puppets_user_id,
)

# the device *should* have been registered before we got here; however,
Expand Down
5 changes: 3 additions & 2 deletions synapse/handlers/deactivate_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def __init__(self, hs: "HomeServer"):
self._room_member_handler = hs.get_room_member_handler()
self._identity_handler = hs.get_identity_handler()
self.user_directory_handler = hs.get_user_directory_handler()
self._server_name = hs.hostname

# Flag that indicates whether the process to part users from rooms is running
self._user_parter_running = False
Expand Down Expand Up @@ -152,7 +153,7 @@ async def _reject_pending_invites_for_user(self, user_id: str) -> None:
for room in pending_invites:
try:
await self._room_member_handler.update_membership(
create_requester(user),
create_requester(user, authenticated_entity=self._server_name),
user,
room.room_id,
"leave",
Expand Down Expand Up @@ -208,7 +209,7 @@ async def _part_user(self, user_id: str) -> None:
logger.info("User parter parting %r from %r", user_id, room_id)
try:
await self._room_member_handler.update_membership(
create_requester(user),
create_requester(user, authenticated_entity=self._server_name),
user,
room_id,
"leave",
Expand Down
21 changes: 10 additions & 11 deletions synapse/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ async def create_event(
Returns:
Tuple of created event, Context
"""
await self.auth.check_auth_blocking(requester.user.to_string())
await self.auth.check_auth_blocking(requester=requester)

if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "":
room_version = event_dict["content"]["room_version"]
Expand Down Expand Up @@ -619,7 +619,13 @@ async def assert_accepted_privacy_policy(self, requester: Requester) -> None:
if requester.app_service is not None:
return

user_id = requester.user.to_string()
user_id = requester.authenticated_entity
if not user_id.startswith("@"):
# The authenticated entity might not be a user, e.g. if it's the
# server puppetting the user.
return
erikjohnston marked this conversation as resolved.
Show resolved Hide resolved

user = UserID.from_string(user_id)

# exempt the system notices user
if (
Expand All @@ -639,9 +645,7 @@ async def assert_accepted_privacy_policy(self, requester: Requester) -> None:
if u["consent_version"] == self.config.user_consent_version:
return

consent_uri = self._consent_uri_builder.build_user_consent_uri(
requester.user.localpart
)
consent_uri = self._consent_uri_builder.build_user_consent_uri(user.localpart)
msg = self._block_events_without_consent_error % {"consent_uri": consent_uri}
raise ConsentNotGivenError(msg=msg, consent_uri=consent_uri)

Expand Down Expand Up @@ -1249,7 +1253,7 @@ async def _send_dummy_event_for_room(self, room_id: str) -> bool:
for user_id in members:
if not self.hs.is_mine_id(user_id):
continue
requester = create_requester(user_id)
requester = create_requester(user_id, authenticated_entity=self.server_name)
try:
event, context = await self.create_event(
requester,
Expand All @@ -1270,11 +1274,6 @@ async def _send_dummy_event_for_room(self, room_id: str) -> bool:
requester, event, context, ratelimit=False, ignore_shadow_ban=True,
)
return True
except ConsentNotGivenError:
logger.info(
"Failed to send dummy event into room %s for user %s due to "
"lack of consent. Will try another user" % (room_id, user_id)
)
except AuthError:
logger.info(
"Failed to send dummy event into room %s for user %s due to "
Expand Down
8 changes: 6 additions & 2 deletions synapse/handlers/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ async def set_displayname(
# the join event to update the displayname in the rooms.
# This must be done by the target user himself.
if by_admin:
requester = create_requester(target_user)
requester = create_requester(
target_user, authenticated_entity=requester.authenticated_entity,
)
Comment on lines 208 to +211
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the caller be updated to pass in a different requester instead of having the by_admin flag?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit of an edge case tbh. The original request has kinda the "right" requester, we could fudge it but then it is a bit less clear whether the admin user is using a puppeted token vs authenticating as themselves 🤷


await self.store.set_profile_displayname(
target_user.localpart, displayname_to_set
Expand Down Expand Up @@ -282,7 +284,9 @@ async def set_avatar_url(

# Same like set_displayname
if by_admin:
requester = create_requester(target_user)
requester = create_requester(
target_user, authenticated_entity=requester.authenticated_entity
)

await self.store.set_profile_avatar_url(target_user.localpart, new_avatar_url)

Expand Down
19 changes: 14 additions & 5 deletions synapse/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def __init__(self, hs):
self.ratelimiter = hs.get_registration_ratelimiter()
self.macaroon_gen = hs.get_macaroon_generator()
self._server_notices_mxid = hs.config.server_notices_mxid
self._server_name = hs.hostname

self.spam_checker = hs.get_spam_checker()

Expand Down Expand Up @@ -317,7 +318,8 @@ async def _create_and_join_rooms(self, user_id: str):
requires_join = False
if self.hs.config.registration.auto_join_user_id:
fake_requester = create_requester(
self.hs.config.registration.auto_join_user_id
self.hs.config.registration.auto_join_user_id,
authenticated_entity=self._server_name,
)

# If the room requires an invite, add the user to the list of invites.
Expand All @@ -329,7 +331,9 @@ async def _create_and_join_rooms(self, user_id: str):
# being necessary this will occur after the invite was sent.
requires_join = True
else:
fake_requester = create_requester(user_id)
fake_requester = create_requester(
user_id, authenticated_entity=self._server_name
)

# Choose whether to federate the new room.
if not self.hs.config.registration.autocreate_auto_join_rooms_federated:
Expand Down Expand Up @@ -362,7 +366,9 @@ async def _create_and_join_rooms(self, user_id: str):
# created it, then ensure the first user joins it.
if requires_join:
await room_member_handler.update_membership(
erikjohnston marked this conversation as resolved.
Show resolved Hide resolved
requester=create_requester(user_id),
requester=create_requester(
user_id, authenticated_entity=self._server_name
),
target=UserID.from_string(user_id),
room_id=info["room_id"],
# Since it was just created, there are no remote hosts.
Expand Down Expand Up @@ -426,7 +432,8 @@ async def _join_rooms(self, user_id: str):
if requires_invite:
await room_member_handler.update_membership(
requester=create_requester(
self.hs.config.registration.auto_join_user_id
self.hs.config.registration.auto_join_user_id,
authenticated_entity=self._server_name,
),
target=UserID.from_string(user_id),
room_id=room_id,
Expand All @@ -437,7 +444,9 @@ async def _join_rooms(self, user_id: str):

# Send the join.
await room_member_handler.update_membership(
requester=create_requester(user_id),
requester=create_requester(
user_id, authenticated_entity=self._server_name
),
target=UserID.from_string(user_id),
room_id=room_id,
remote_room_hosts=remote_room_hosts,
Expand Down
10 changes: 7 additions & 3 deletions synapse/handlers/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ async def create_room(
"""
user_id = requester.user.to_string()

await self.auth.check_auth_blocking(user_id)
await self.auth.check_auth_blocking(requester=requester)

if (
self._server_notices_mxid is not None
Expand Down Expand Up @@ -1257,7 +1257,9 @@ async def shutdown_room(
400, "User must be our own: %s" % (new_room_user_id,)
)

room_creator_requester = create_requester(new_room_user_id)
room_creator_requester = create_requester(
new_room_user_id, authenticated_entity=requester_user_id
)

info, stream_id = await self._room_creation_handler.create_room(
room_creator_requester,
Expand Down Expand Up @@ -1297,7 +1299,9 @@ async def shutdown_room(

try:
# Kick users from room
target_requester = create_requester(user_id)
target_requester = create_requester(
user_id, authenticated_entity=requester_user_id
)
_, stream_id = await self.room_member_handler.update_membership(
requester=target_requester,
target=target_requester.user,
Expand Down
5 changes: 4 additions & 1 deletion synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,7 @@ def __init__(self, hs):

self.distributor = hs.get_distributor()
self.distributor.declare("user_left_room")
self._server_name = hs.hostname

async def _is_remote_room_too_complex(
self, room_id: str, remote_room_hosts: List[str]
Expand Down Expand Up @@ -1059,7 +1060,9 @@ async def _remote_join(
return event_id, stream_id

# The room is too large. Leave.
requester = types.create_requester(user, None, False, False, None)
requester = types.create_requester(
user, authenticated_entity=self._server_name
)
await self.update_membership(
requester=requester, target=user, room_id=room_id, action="leave"
)
Expand Down
4 changes: 3 additions & 1 deletion synapse/handlers/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Collection,
JsonDict,
MutableStateMap,
Requester,
RoomStreamToken,
StateMap,
StreamToken,
Expand Down Expand Up @@ -260,6 +261,7 @@ def __init__(self, hs: "HomeServer"):

async def wait_for_sync_for_user(
self,
requester: Requester,
sync_config: SyncConfig,
since_token: Optional[StreamToken] = None,
timeout: int = 0,
Expand All @@ -273,7 +275,7 @@ async def wait_for_sync_for_user(
# not been exceeded (if not part of the group by this point, almost certain
# auth_blocking will occur)
user_id = sync_config.user.to_string()
await self.auth.check_auth_blocking(user_id)
await self.auth.check_auth_blocking(requester=requester)

res = await self.response_cache.wrap(
sync_config.request_key,
Expand Down