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

Commit

Permalink
Add admin API for logging in as a user
Browse files Browse the repository at this point in the history
Fixes #6054.
  • Loading branch information
erikjohnston committed Oct 21, 2020
1 parent 9edb5b3 commit 8d08cf7
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 6 deletions.
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 @@ -341,6 +341,41 @@ The following fields are returned in the JSON response body:
- ``total`` - Number of rooms.


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
24 changes: 20 additions & 4 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,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 @@ -713,13 +717,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
2 changes: 2 additions & 0 deletions synapse/rest/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
UserRestServletV2,
UsersRestServlet,
UsersRestServletV2,
UserTokenRestServlet,
WhoisRestServlet,
)
from synapse.types import RoomStreamToken
Expand Down Expand Up @@ -216,6 +217,7 @@ def register_servlets(hs, http_server):
VersionServlet(hs).register(http_server)
UserAdminServlet(hs).register(http_server)
UserMembershipRestServlet(hs).register(http_server)
UserTokenRestServlet(hs).register(http_server)
UserRestServletV2(hs).register(http_server)
UsersRestServletV2(hs).register(http_server)
DeviceRestServlet(hs).register(http_server)
Expand Down
43 changes: 43 additions & 0 deletions synapse/rest/admin/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import hmac
import logging
from http import HTTPStatus
from typing import TYPE_CHECKING

from synapse.api.constants import UserTypes
from synapse.api.errors import Codes, NotFoundError, SynapseError
Expand All @@ -35,6 +36,9 @@
)
from synapse.types import UserID

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -708,3 +712,42 @@ async def on_GET(self, request, user_id):

ret = {"joined_rooms": list(room_ids), "total": len(room_ids)}
return 200, ret


class UserTokenRestServlet(RestServlet):
"""An admin API for logging in as a user.
"""

PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/login$")

def __init__(self, hs: "HomeServer"):
self.hs = hs
self.store = hs.get_datastore()
self.auth = hs.get_auth()
self.auth_handler = hs.get_auth_handler()

async def on_PUT(self, request, user_id):
requester = await self.auth.get_user_by_req(request)
await assert_user_is_admin(self.auth, requester.user)
auth_user = requester.user

if not self.hs.is_mine_id(user_id):
raise SynapseError(400, "Only local users can be logged in as")

body = parse_json_object_from_request(request)

valid_until_ms = body.get("valid_until_ms")
if valid_until_ms and not isinstance(valid_until_ms, int):
raise SynapseError(400, "'valid_until_ms' parameter must be an int")

if auth_user.to_string() == user_id:
raise SynapseError(400, "Cannot use admin API to login as self")

token = await self.auth_handler.get_access_token_for_user_id(
user_id=auth_user.to_string(),
device_id=None,
valid_until_ms=valid_until_ms,
puppets_user_id=user_id,
)

return 200, {"access_token": token}
2 changes: 2 additions & 0 deletions synapse/storage/databases/main/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,7 @@ async def add_access_token_to_user(
token: str,
device_id: Optional[str],
valid_until_ms: Optional[int],
puppets_user_id: Optional[str] = None,
) -> int:
"""Adds an access token for the given user.
Expand All @@ -1124,6 +1125,7 @@ async def add_access_token_to_user(
"token": token,
"device_id": device_id,
"valid_until_ms": valid_until_ms,
"puppets_user_id": puppets_user_id,
},
desc="add_access_token_to_user",
)
Expand Down
192 changes: 190 additions & 2 deletions tests/rest/admin/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
import synapse.rest.admin
from synapse.api.constants import UserTypes
from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError
from synapse.rest.client.v1 import login, room
from synapse.rest.client.v2_alpha import sync
from synapse.rest.client.v1 import login, logout, room
from synapse.rest.client.v2_alpha import devices, sync

from tests import unittest
from tests.test_utils import make_awaitable
Expand Down Expand Up @@ -1101,3 +1101,191 @@ def test_get_rooms(self):
self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(number_rooms, channel.json_body["total"])
self.assertEqual(number_rooms, len(channel.json_body["joined_rooms"]))


class UserTokenRestTestCase(unittest.HomeserverTestCase):
"""Test for /_synapse/admin/v1/users/<user>/login
"""

servlets = [
synapse.rest.admin.register_servlets,
login.register_servlets,
sync.register_servlets,
room.register_servlets,
devices.register_servlets,
logout.register_servlets,
]

def prepare(self, reactor, clock, hs):
self.store = hs.get_datastore()

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_tok = self.login("user", "pass")
self.url = "/_synapse/admin/v1/users/%s/login" % urllib.parse.quote(
self.other_user
)

def _get_token(self) -> str:
request, channel = self.make_request(
"PUT", self.url, b"{}", access_token=self.admin_user_tok
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
return channel.json_body["access_token"]

def test_no_auth(self):
"""Try to login as a user without authentication.
"""
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"])

def test_not_admin(self):
"""Try to login as a user as a non-admin user.
"""
request, channel = self.make_request(
"PUT", self.url, b"{}", access_token=self.other_user_tok
)
self.render(request)

self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"])

def test_send_event(self):
"""Test that sending event as a user works.
"""
# Create a room.
room_id = self.helper.create_room_as(self.other_user, tok=self.other_user_tok)

# Login in as the user
puppet_token = self._get_token()

# Test that sending works, and generates the event as the right user.
resp = self.helper.send_event(room_id, "com.example.test", tok=puppet_token)
event_id = resp["event_id"]
event = self.get_success(self.store.get_event(event_id))
self.assertEqual(event.sender, self.other_user)

def test_devices(self):
"""Tests that logging in as a user doesn't create a new device for them.
"""
# Login in as the user
self._get_token()

# Check that we don't see a new device in our devices list
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=self.other_user_tok
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# We should only see the one device (from the login in `prepare`)
self.assertEqual(len(channel.json_body["devices"]), 1)

def test_logout(self):
"""Test that calling `/logout` with the token works.
"""
# Login in as the user
puppet_token = self._get_token()

# Test that we can successfully make a request
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# Logout with the puppet token
request, channel = self.make_request(
"POST", "logout", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# The puppet token should no longer work
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"])

# .. but the real user's tokens should still work
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=self.other_user_tok
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

def test_user_logout_all(self):
"""Tests that the target user calling `/logout/all` does *not* expire
the token.
"""
# Login in as the user
puppet_token = self._get_token()

# Test that we can successfully make a request
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# Logout all with the real user token
request, channel = self.make_request(
"POST", "logout/all", b"{}", access_token=self.other_user_tok
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# The puppet token should still work
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# .. but the real user's tokens shouldn't
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=self.other_user_tok
)
self.render(request)
self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"])

def test_admin_logout_all(self):
"""Tests that the admin user calling `/logout/all` does expire the
token.
"""
# Login in as the user
puppet_token = self._get_token()

# Test that we can successfully make a request
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# Logout all with the admin user token
request, channel = self.make_request(
"POST", "logout/all", b"{}", access_token=self.admin_user_tok
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

# The puppet token should no longer work
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=puppet_token
)
self.render(request)
self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"])

# .. but the real user's tokens should still work
request, channel = self.make_request(
"GET", "devices", b"{}", access_token=self.other_user_tok
)
self.render(request)
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])

0 comments on commit 8d08cf7

Please sign in to comment.