parent
5337d4d98e
commit
332ad5ea52
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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))
|
||||
|
121
maubot/management/api/client_auth.py
Normal file
121
maubot/management/api/client_auth.py
Normal 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)
|
58
maubot/management/api/client_proxy.py
Normal file
58
maubot/management/api/client_proxy.py
Normal 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
|
@ -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)
|
||||
err = check_token(request)
|
||||
if err is not None:
|
||||
return err
|
||||
return await handler(request)
|
||||
return check_token(request) or await handler(request)
|
||||
|
||||
|
||||
log = logging.getLogger("maubot.server")
|
||||
|
@ -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({
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
),
|
||||
|
Loading…
Reference in New Issue
Block a user