Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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::

POST /_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
33 changes: 29 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,47 @@ 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. Passing in both a `user_id` and
`requester` is an error.
"""
if requester and user_id:
raise Exception(
"Passed in both 'user_id' and 'requester' to 'check_auth_blocking'"
)

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 @@ -698,8 +698,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 @@ -725,13 +729,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

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 @@ -1252,7 +1256,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 @@ -1273,11 +1277,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 @@ -206,7 +206,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
Member

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 @@ -286,7 +288,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
24 changes: 14 additions & 10 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,19 +366,16 @@ 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(
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.
remote_room_hosts=[],
action="join",
ratelimit=False,
)

except ConsentNotGivenError as e:
# Technically not necessary to pull out this error though
# moving away from bare excepts is a good thing to do.
logger.error("Failed to join new user to %r: %r", r, e)
except Exception as e:
logger.error("Failed to join new user to %r: %r", r, e)

Expand Down Expand Up @@ -426,7 +427,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 +439,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
Loading