Add Matrix register/login API and full Matrix API proxy

(ref #19, #15)
This commit is contained in:
Tulir Asokan 2018-12-08 01:13:40 +02:00
parent 5337d4d98e
commit 332ad5ea52
13 changed files with 238 additions and 36 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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):

View file

@ -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

View file

@ -15,7 +15,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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))

View file

@ -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 <https://www.gnu.org/licenses/>.
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)

View file

@ -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 <https://www.gnu.org/licenses/>.
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

View file

@ -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")

View file

@ -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({

View file

@ -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",

View file

@ -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)

View file

@ -31,7 +31,7 @@ const ClientListEntry = ({ entry }) => {
}
return (
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
<img className="avatar" src={api.getAvatarURL(entry.id)} alt=""/>
<img className="avatar" src={api.getAvatarURL(entry)} alt=""/>
<span className="displayname">{entry.displayname || entry.id}</span>
<ChevronRight/>
</NavLink>
@ -129,7 +129,7 @@ class Client extends BaseMainView {
<div className="sidebar">
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
${this.state.uploadingAvatar ? "uploading" : ""}`}>
<img className="avatar" src={api.getAvatarURL(this.state.id)} alt="Avatar"/>
<img className="avatar" src={api.getAvatarURL(this.state)} alt="Avatar"/>
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="image/png, image/jpeg"
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}

View file

@ -73,7 +73,7 @@ class Instance extends BaseMainView {
value: client.id,
label: (
<div className="select-client">
<img className="avatar" src={api.getAvatarURL(client.id)} alt=""/>
<img className="avatar" src={api.getAvatarURL(client)} alt=""/>
<span className="displayname">{client.displayname || client.id}</span>
</div>
),