From 332ad5ea52611c51650638fa57b5baec9ca0055b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 8 Dec 2018 01:13:40 +0200 Subject: [PATCH] Add Matrix register/login API and full Matrix API proxy (ref #19, #15) --- docker/example-config.yaml | 8 ++ example-config.yaml | 8 ++ maubot/config.py | 1 + maubot/management/api/__init__.py | 2 + maubot/management/api/client.py | 25 ---- maubot/management/api/client_auth.py | 121 ++++++++++++++++++ maubot/management/api/client_proxy.py | 58 +++++++++ maubot/management/api/middleware.py | 9 +- maubot/management/api/responses.py | 21 +++ maubot/management/frontend/.eslintrc.json | 3 - maubot/management/frontend/src/api.js | 12 +- .../frontend/src/pages/dashboard/Client.js | 4 +- .../frontend/src/pages/dashboard/Instance.js | 2 +- 13 files changed, 238 insertions(+), 36 deletions(-) create mode 100644 maubot/management/api/client_auth.py create mode 100644 maubot/management/api/client_proxy.py diff --git a/docker/example-config.yaml b/docker/example-config.yaml index a273629..6ac3492 100644 --- a/docker/example-config.yaml +++ b/docker/example-config.yaml @@ -35,6 +35,14 @@ server: # Set to "generate" to generate and save a new token at startup. unshared_secret: generate +# Shared registration secrets to allow registering new users from the management UI +registration_secrets: + example.com: + # Client-server API URL + url: https://example.com + # registration_shared_secret from synapse config + secret: synapse_shared_registration_secret + # List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password # to prevent normal login. Root is a special user that can't have a password and will always exist. admins: diff --git a/example-config.yaml b/example-config.yaml index 1b02d67..384d877 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -35,6 +35,14 @@ server: # Set to "generate" to generate and save a new token at startup. unshared_secret: generate +# Shared registration secrets to allow registering new users from the management UI +registration_secrets: + example.com: + # Client-server API URL + url: https://example.com + # registration_shared_secret from synapse config + secret: synapse_shared_registration_secret + # List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password # to prevent normal login. Root is a special user that can't have a password and will always exist. admins: diff --git a/maubot/config.py b/maubot/config.py index ea8dd3c..ab3e080 100644 --- a/maubot/config.py +++ b/maubot/config.py @@ -47,6 +47,7 @@ class Config(BaseFileConfig): base["server.unshared_secret"] = self._new_token() else: base["server.unshared_secret"] = shared_secret + copy("registration_secrets") copy("admins") for username, password in base["admins"].items(): if password and not bcrypt_regex.match(password): diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py index 760299e..93e994d 100644 --- a/maubot/management/api/__init__.py +++ b/maubot/management/api/__init__.py @@ -23,6 +23,8 @@ from .auth import web as _ from .plugin import web as _ from .instance import web as _ from .client import web as _ +from .client_proxy import web as _ +from .client_auth import web as _ from .dev_open import web as _ from .log import stop_all as stop_log_sockets, init as init_log_listener diff --git a/maubot/management/api/client.py b/maubot/management/api/client.py index 2975581..8fbf894 100644 --- a/maubot/management/api/client.py +++ b/maubot/management/api/client.py @@ -15,7 +15,6 @@ # along with this program. If not, see . from typing import Optional from json import JSONDecodeError -from http import HTTPStatus from aiohttp import web @@ -131,27 +130,3 @@ async def delete_client(request: web.Request) -> web.Response: await client.stop() client.delete() return resp.deleted - - -@routes.post("/client/{id}/avatar") -async def upload_avatar(request: web.Request) -> web.Response: - user_id = request.match_info.get("id", None) - client = Client.get(user_id, None) - if not client: - return resp.client_not_found - content = await request.read() - return web.json_response({ - "content_uri": await client.client.upload_media( - content, request.headers.get("Content-Type", None)), - }) - - -@routes.get("/client/{id}/avatar") -async def download_avatar(request: web.Request) -> web.Response: - user_id = request.match_info.get("id", None) - client = Client.get(user_id, None) - if not client: - return resp.client_not_found - if not client.avatar_url or client.avatar_url == "disable": - return web.Response() - return web.Response(body=await client.client.download_media(client.avatar_url)) diff --git a/maubot/management/api/client_auth.py b/maubot/management/api/client_auth.py new file mode 100644 index 0000000..ec5d4d3 --- /dev/null +++ b/maubot/management/api/client_auth.py @@ -0,0 +1,121 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Dict, Tuple, NamedTuple, Optional +from json import JSONDecodeError +import hmac +import hashlib + +from aiohttp import web +from mautrix.api import HTTPAPI, Path, Method +from mautrix.errors import MatrixRequestError + +from .base import routes, get_config, get_loop +from .responses import resp + + +def registration_secrets() -> Dict[str, Dict[str, str]]: + return get_config()["registration_secrets"] + + +def generate_mac(secret: str, nonce: str, user: str, password: str, admin: bool = False): + mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1) + mac.update(nonce.encode("utf-8")) + mac.update(b"\x00") + mac.update(user.encode("utf-8")) + mac.update(b"\x00") + mac.update(password.encode("utf-8")) + mac.update(b"\x00") + mac.update(b"admin" if admin else b"notadmin") + return mac.hexdigest() + + +@routes.get("/client/auth/servers") +async def get_registerable_servers(_: web.Request) -> web.Response: + return web.json_response(list(registration_secrets().keys())) + + +AuthRequestInfo = NamedTuple("AuthRequestInfo", api=HTTPAPI, secret=str, username=str, password=str) + + +async def read_client_auth_request(request: web.Request) -> Tuple[Optional[AuthRequestInfo], + Optional[web.Response]]: + server_name = request.match_info.get("server", None) + server = registration_secrets().get(server_name, None) + if not server: + return None, resp.server_not_found + try: + body = await request.json() + except JSONDecodeError: + return None, resp.body_not_json + try: + username = body["username"] + password = body["password"] + except KeyError: + return None, resp.username_or_password_missing + try: + base_url = server["url"] + secret = server["secret"] + except KeyError: + return None, resp.invalid_server + api = HTTPAPI(base_url, "", loop=get_loop()) + return (api, secret, username, password), None + + +@routes.post("/client/auth/{server}/register") +async def register(request: web.Request) -> web.Response: + info, err = await read_client_auth_request(request) + if err is not None: + return err + api, secret, username, password = info + res = await api.request(Method.GET, Path.admin.register) + nonce = res["nonce"] + mac = generate_mac(secret, nonce, username, password) + try: + return web.json_response(await api.request(Method.POST, Path.admin.register, content={ + "nonce": nonce, + "username": username, + "password": password, + "admin": False, + "mac": mac, + })) + except MatrixRequestError as e: + return web.json_response({ + "errcode": e.errcode, + "error": e.message, + }, status=e.http_status) + + +@routes.post("/client/auth/{server}/login") +async def login(request: web.Request) -> web.Response: + info, err = await read_client_auth_request(request) + if err is not None: + return err + api, _, username, password = info + try: + return web.json_response(await api.request(Method.POST, Path.login, content={ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": username, + }, + "password": password, + "device_id": "maubot", + })) + except MatrixRequestError as e: + return web.json_response({ + "errcode": e.errcode, + "error": e.message, + }, status=e.http_status) diff --git a/maubot/management/api/client_proxy.py b/maubot/management/api/client_proxy.py new file mode 100644 index 0000000..b6f5787 --- /dev/null +++ b/maubot/management/api/client_proxy.py @@ -0,0 +1,58 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from aiohttp import web, client as http + +from ...client import Client +from .base import routes +from .responses import resp + +PROXY_CHUNK_SIZE = 32 * 1024 + + +@routes.view("/proxy/{id}/{path:_matrix/.+}") +async def proxy(request: web.Request) -> web.StreamResponse: + user_id = request.match_info.get("id", None) + client = Client.get(user_id, None) + if not client: + return resp.client_not_found + + path = request.match_info.get("path", None) + query = request.query.copy() + try: + del query["access_token"] + except KeyError: + pass + headers = request.headers.copy() + headers["Authorization"] = f"Bearer {client.access_token}" + if "X-Forwarded-For" not in headers: + peer = request.transport.get_extra_info("peername") + if peer is not None: + host, port = peer + headers["X-Forwarded-For"] = f"{host}:{port}" + + data = await request.read() + chunked = PROXY_CHUNK_SIZE if not data else None + async with http.request(request.method, f"{client.homeserver}/{path}", headers=headers, + params=query, chunked=chunked, data=data) as proxy_resp: + response = web.StreamResponse(status=proxy_resp.status, headers=proxy_resp.headers) + await response.prepare(request) + content = proxy_resp.content + chunk = await content.read(PROXY_CHUNK_SIZE) + while chunk: + await response.write(chunk) + chunk = await content.read(PROXY_CHUNK_SIZE) + await response.write_eof() + return response diff --git a/maubot/management/api/middleware.py b/maubot/management/api/middleware.py index f58dcd8..538dba5 100644 --- a/maubot/management/api/middleware.py +++ b/maubot/management/api/middleware.py @@ -20,15 +20,20 @@ from aiohttp import web from .responses import resp from .auth import check_token +from .base import get_config Handler = Callable[[web.Request], Awaitable[web.Response]] @web.middleware async def auth(request: web.Request, handler: Handler) -> web.Response: - if "/auth/" in request.path: + subpath = request.path.lstrip(get_config()["server.base_path"]) + if subpath.startswith("/auth/") or subpath == "/logs" or subpath == "logs": return await handler(request) - return check_token(request) or await handler(request) + err = check_token(request) + if err is not None: + return err + return await handler(request) log = logging.getLogger("maubot.server") diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py index 5204927..34fd110 100644 --- a/maubot/management/api/responses.py +++ b/maubot/management/api/responses.py @@ -75,6 +75,13 @@ class _Response: "errcode": "pid_mismatch", }, status=HTTPStatus.BAD_REQUEST) + @property + def username_or_password_missing(self) -> web.Response: + return web.json_response({ + "error": "Username or password missing", + "errcode": "username_or_password_missing", + }, status=HTTPStatus.BAD_REQUEST) + @property def bad_auth(self) -> web.Response: return web.json_response({ @@ -138,6 +145,13 @@ class _Response: "errcode": "resource_not_found", }, status=HTTPStatus.NOT_FOUND) + @property + def server_not_found(self) -> web.Response: + return web.json_response({ + "error": "Registration target server not found", + "errcode": "server_not_found", + }, status=HTTPStatus.NOT_FOUND) + @property def method_not_allowed(self) -> web.Response: return web.json_response({ @@ -196,6 +210,13 @@ class _Response: "errcode": "internal_server_error", }, status=HTTPStatus.INTERNAL_SERVER_ERROR) + @property + def invalid_server(self) -> web.Response: + return web.json_response({ + "error": "Invalid registration server object in maubot configuration", + "errcode": "invalid_server", + }, status=HTTPStatus.INTERNAL_SERVER_ERROR) + @property def unsupported_plugin_loader(self) -> web.Response: return web.json_response({ diff --git a/maubot/management/frontend/.eslintrc.json b/maubot/management/frontend/.eslintrc.json index 4052abf..8451d44 100644 --- a/maubot/management/frontend/.eslintrc.json +++ b/maubot/management/frontend/.eslintrc.json @@ -34,9 +34,6 @@ "semi": ["error", "never"], "comma-dangle": ["error", "always-multiline"], "max-len": ["warn", 100], - "camelcase": ["error", { - "properties": "always" - }], "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js index 49465af..d01952e 100644 --- a/maubot/management/frontend/src/api.js +++ b/maubot/management/frontend/src/api.js @@ -138,6 +138,7 @@ export const updateDebugOpenFileEnabled = async () => { const resp = await defaultGet("/debug/open") _debugOpenFileEnabled = resp["enabled"] || false } + export async function debugOpenFile(path, line) { const resp = await fetch(`${BASE_PATH}/debug/open`, { headers: getHeaders(), @@ -178,7 +179,7 @@ export const getClients = () => defaultGet("/clients") export const getClient = id => defaultGet(`/clients/${id}`) export async function uploadAvatar(id, data, mime) { - const resp = await fetch(`${BASE_PATH}/client/${id}/avatar`, { + const resp = await fetch(`${BASE_PATH}/proxy/${id}/_matrix/media/r0/upload`, { headers: getHeaders(mime), body: data, method: "POST", @@ -186,8 +187,13 @@ export async function uploadAvatar(id, data, mime) { return await resp.json() } -export function getAvatarURL(id) { - return `${BASE_PATH}/client/${id}/avatar?access_token=${localStorage.accessToken}` +export function getAvatarURL({ id, avatar_url }) { + avatar_url = avatar_url || "" + if (avatar_url.startsWith("mxc://")) { + avatar_url = avatar_url.substr("mxc://".length) + } + return `${BASE_PATH}/proxy/${id}/_matrix/media/r0/download/${avatar_url}?access_token=${ + localStorage.accessToken}` } export const putClient = client => defaultPut("client", client) diff --git a/maubot/management/frontend/src/pages/dashboard/Client.js b/maubot/management/frontend/src/pages/dashboard/Client.js index e520c26..c4ed60d 100644 --- a/maubot/management/frontend/src/pages/dashboard/Client.js +++ b/maubot/management/frontend/src/pages/dashboard/Client.js @@ -31,7 +31,7 @@ const ClientListEntry = ({ entry }) => { } return ( - + {entry.displayname || entry.id} @@ -129,7 +129,7 @@ class Client extends BaseMainView {
- Avatar + Avatar - + {client.displayname || client.id}
),