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

Allow appservice users to /login #8320

Merged
merged 12 commits into from
Sep 18, 2020
1 change: 1 addition & 0 deletions changelog.d/8320.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `uk.half-shot.msc2778.login.application_service` login type to allow appservices to login.
49 changes: 39 additions & 10 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from synapse.api.errors import Codes, LoginError, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.appservice import ApplicationService
from synapse.handlers.auth import (
convert_client_dict_legacy_fields_to_identifier,
login_id_phone_to_thirdparty,
Expand All @@ -44,6 +45,7 @@ class LoginRestServlet(RestServlet):
TOKEN_TYPE = "m.login.token"
JWT_TYPE = "org.matrix.login.jwt"
JWT_TYPE_DEPRECATED = "m.login.jwt"
APPSERVICE_TYPE = "uk.half-shot.msc2778.login.application_service"
Half-Shot marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, hs):
super(LoginRestServlet, self).__init__()
Expand All @@ -61,6 +63,8 @@ def __init__(self, hs):
self.cas_enabled = hs.config.cas_enabled
self.oidc_enabled = hs.config.oidc_enabled

self.auth = hs.get_auth()

self.auth_handler = self.hs.get_auth_handler()
self.registration_handler = hs.get_registration_handler()
self.handlers = hs.get_handlers()
Expand Down Expand Up @@ -107,6 +111,8 @@ def on_GET(self, request: SynapseRequest):
({"type": t} for t in self.auth_handler.get_supported_login_types())
)

flows.append({"type": LoginRestServlet.APPSERVICE_TYPE})

return 200, {"flows": flows}

def on_OPTIONS(self, request: SynapseRequest):
Expand All @@ -116,8 +122,12 @@ async def on_POST(self, request: SynapseRequest):
self._address_ratelimiter.ratelimit(request.getClientIP())

login_submission = parse_json_object_from_request(request)

try:
if self.jwt_enabled and (
if login_submission["type"] == LoginRestServlet.APPSERVICE_TYPE:
appservice = self.auth.get_appservice_by_req(request)
result = await self._do_appservice_login(login_submission, appservice)
elif self.jwt_enabled and (
login_submission["type"] == LoginRestServlet.JWT_TYPE
or login_submission["type"] == LoginRestServlet.JWT_TYPE_DEPRECATED
):
Expand All @@ -134,6 +144,33 @@ async def on_POST(self, request: SynapseRequest):
result["well_known"] = well_known_data
return 200, result

def _get_qualified_user_id(self, identifier):
if identifier["type"] != "m.id.user":
raise SynapseError(400, "Unknown login identifier type")
if "user" not in identifier:
raise SynapseError(400, "User identifier is missing 'user' key")

if identifier["user"].startswith("@"):
return identifier["user"]
else:
return UserID(identifier["user"], self.hs.hostname).to_string()

async def _do_appservice_login(
self, login_submission: JsonDict, appservice: ApplicationService
):
logger.info(
"Got appservice login request with identifier: %r",
login_submission.get("identifier"),
)

identifier = convert_client_dict_legacy_fields_to_identifier(login_submission)
Half-Shot marked this conversation as resolved.
Show resolved Hide resolved
qualified_user_id = self._get_qualified_user_id(identifier)

if not appservice.is_interested_in_user(qualified_user_id):
raise LoginError(403, "Invalid access_token", errcode=Codes.FORBIDDEN)

return await self._complete_login(qualified_user_id, login_submission)

async def _do_other_login(self, login_submission: JsonDict) -> Dict[str, str]:
"""Handle non-token/saml/jwt logins

Expand Down Expand Up @@ -219,15 +256,7 @@ async def _do_other_login(self, login_submission: JsonDict) -> Dict[str, str]:

# by this point, the identifier should be an m.id.user: if it's anything
# else, we haven't understood it.
if identifier["type"] != "m.id.user":
raise SynapseError(400, "Unknown login identifier type")
if "user" not in identifier:
raise SynapseError(400, "User identifier is missing 'user' key")

if identifier["user"].startswith("@"):
qualified_user_id = identifier["user"]
else:
qualified_user_id = UserID(identifier["user"], self.hs.hostname).to_string()
qualified_user_id = self._get_qualified_user_id(identifier)

# Check if we've hit the failed ratelimit (but don't update it)
self._failed_attempts_ratelimiter.ratelimit(
Expand Down
74 changes: 73 additions & 1 deletion tests/rest/client/v1/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import jwt

import synapse.rest.admin
from synapse.appservice import ApplicationService
from synapse.rest.client.v1 import login, logout
from synapse.rest.client.v2_alpha import devices
from synapse.rest.client.v2_alpha import devices, register
from synapse.rest.client.v2_alpha.account import WhoamiRestServlet

from tests import unittest
Expand Down Expand Up @@ -748,3 +749,74 @@ def test_login_jwt_invalid_signature(self):
channel.json_body["error"],
"JWT validation failed: Signature verification failed",
)


AS_USER = "as_user_alice"


class AppserviceLoginRestServletTestCase(unittest.HomeserverTestCase):
servlets = [
login.register_servlets,
register.register_servlets,
lambda hs, http_server: WhoamiRestServlet(hs).register(http_server),
Copy link
Member

Choose a reason for hiding this comment

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

Looks like this gets registered with the rest of the account ones, did you do the lambda just to avoid registering all of them?

I think the code looks fine, I'd like to see some tests for the error scenarioes. The main one I can think of is:

  • Trying to login as a user that the appservice isn't interested in.

I suspect the other cases are all pretty covered already?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Looks like this gets registered with the rest of the account ones, did you do the lambda just to avoid registering all of them?

Heh, no I copy and pasted it without thinking. Fixed.

I'd like to see some tests for the error scenarios.

Added tests for all the other scenarios I could think of.

]

def register_as_user(self, username):
request, channel = self.make_request(
b"POST",
"/_matrix/client/r0/register?access_token=%s" % (self.service.token,),
{"username": username},
)
self.render(request)

def make_homeserver(self, reactor, clock):
self.hs = self.setup_test_homeserver()

self.service = ApplicationService(
id="unique_identifier",
token="some_token",
hostname="example.com",
sender="@asbot:example.com",
namespaces={
ApplicationService.NS_USERS: [
{"regex": r"@as_user.*", "exclusive": False}
],
ApplicationService.NS_ROOMS: [],
ApplicationService.NS_ALIASES: [],
},
)

self.hs.get_datastore().services_cache.append(self.service)
return self.hs

def test_login_appservice_user(self):
"""Test that an appservice user can use /login
"""
self.register_as_user(AS_USER)

params = {
"type": login.LoginRestServlet.APPSERVICE_TYPE,
"identifier": {"type": "m.id.user", "user": AS_USER},
}
request, channel = self.make_request(
b"POST", LOGIN_URL, params, access_token=self.service.token
)

self.render(request)
self.assertEquals(channel.result["code"], b"200", channel.result)

def test_login_appservice_user_bot(self):
"""Test that the appservice bot can use /login
"""
self.register_as_user(AS_USER)

params = {
"type": login.LoginRestServlet.APPSERVICE_TYPE,
"identifier": {"type": "m.id.user", "user": self.service.sender},
}
request, channel = self.make_request(
b"POST", LOGIN_URL, params, access_token=self.service.token
)

self.render(request)
self.assertEquals(channel.result["code"], b"200", channel.result)