parent
5337d4d98e
commit
332ad5ea52
13 changed files with 238 additions and 36 deletions
|
@ -35,6 +35,14 @@ server:
|
||||||
# Set to "generate" to generate and save a new token at startup.
|
# Set to "generate" to generate and save a new token at startup.
|
||||||
unshared_secret: generate
|
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
|
# 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.
|
# to prevent normal login. Root is a special user that can't have a password and will always exist.
|
||||||
admins:
|
admins:
|
||||||
|
|
|
@ -35,6 +35,14 @@ server:
|
||||||
# Set to "generate" to generate and save a new token at startup.
|
# Set to "generate" to generate and save a new token at startup.
|
||||||
unshared_secret: generate
|
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
|
# 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.
|
# to prevent normal login. Root is a special user that can't have a password and will always exist.
|
||||||
admins:
|
admins:
|
||||||
|
|
|
@ -47,6 +47,7 @@ class Config(BaseFileConfig):
|
||||||
base["server.unshared_secret"] = self._new_token()
|
base["server.unshared_secret"] = self._new_token()
|
||||||
else:
|
else:
|
||||||
base["server.unshared_secret"] = shared_secret
|
base["server.unshared_secret"] = shared_secret
|
||||||
|
copy("registration_secrets")
|
||||||
copy("admins")
|
copy("admins")
|
||||||
for username, password in base["admins"].items():
|
for username, password in base["admins"].items():
|
||||||
if password and not bcrypt_regex.match(password):
|
if password and not bcrypt_regex.match(password):
|
||||||
|
|
|
@ -23,6 +23,8 @@ from .auth import web as _
|
||||||
from .plugin import web as _
|
from .plugin import web as _
|
||||||
from .instance import web as _
|
from .instance import web as _
|
||||||
from .client 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 .dev_open import web as _
|
||||||
from .log import stop_all as stop_log_sockets, init as init_log_listener
|
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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
@ -131,27 +130,3 @@ async def delete_client(request: web.Request) -> web.Response:
|
||||||
await client.stop()
|
await client.stop()
|
||||||
client.delete()
|
client.delete()
|
||||||
return resp.deleted
|
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 .responses import resp
|
||||||
from .auth import check_token
|
from .auth import check_token
|
||||||
|
from .base import get_config
|
||||||
|
|
||||||
Handler = Callable[[web.Request], Awaitable[web.Response]]
|
Handler = Callable[[web.Request], Awaitable[web.Response]]
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def auth(request: web.Request, handler: Handler) -> web.Response:
|
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 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")
|
log = logging.getLogger("maubot.server")
|
||||||
|
|
|
@ -75,6 +75,13 @@ class _Response:
|
||||||
"errcode": "pid_mismatch",
|
"errcode": "pid_mismatch",
|
||||||
}, status=HTTPStatus.BAD_REQUEST)
|
}, 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
|
@property
|
||||||
def bad_auth(self) -> web.Response:
|
def bad_auth(self) -> web.Response:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
@ -138,6 +145,13 @@ class _Response:
|
||||||
"errcode": "resource_not_found",
|
"errcode": "resource_not_found",
|
||||||
}, status=HTTPStatus.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
|
@property
|
||||||
def method_not_allowed(self) -> web.Response:
|
def method_not_allowed(self) -> web.Response:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
@ -196,6 +210,13 @@ class _Response:
|
||||||
"errcode": "internal_server_error",
|
"errcode": "internal_server_error",
|
||||||
}, status=HTTPStatus.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
|
@property
|
||||||
def unsupported_plugin_loader(self) -> web.Response:
|
def unsupported_plugin_loader(self) -> web.Response:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|
|
@ -34,9 +34,6 @@
|
||||||
"semi": ["error", "never"],
|
"semi": ["error", "never"],
|
||||||
"comma-dangle": ["error", "always-multiline"],
|
"comma-dangle": ["error", "always-multiline"],
|
||||||
"max-len": ["warn", 100],
|
"max-len": ["warn", 100],
|
||||||
"camelcase": ["error", {
|
|
||||||
"properties": "always"
|
|
||||||
}],
|
|
||||||
"space-before-function-paren": ["error", {
|
"space-before-function-paren": ["error", {
|
||||||
"anonymous": "never",
|
"anonymous": "never",
|
||||||
"named": "never",
|
"named": "never",
|
||||||
|
|
|
@ -138,6 +138,7 @@ export const updateDebugOpenFileEnabled = async () => {
|
||||||
const resp = await defaultGet("/debug/open")
|
const resp = await defaultGet("/debug/open")
|
||||||
_debugOpenFileEnabled = resp["enabled"] || false
|
_debugOpenFileEnabled = resp["enabled"] || false
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function debugOpenFile(path, line) {
|
export async function debugOpenFile(path, line) {
|
||||||
const resp = await fetch(`${BASE_PATH}/debug/open`, {
|
const resp = await fetch(`${BASE_PATH}/debug/open`, {
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
|
@ -178,7 +179,7 @@ export const getClients = () => defaultGet("/clients")
|
||||||
export const getClient = id => defaultGet(`/clients/${id}`)
|
export const getClient = id => defaultGet(`/clients/${id}`)
|
||||||
|
|
||||||
export async function uploadAvatar(id, data, mime) {
|
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),
|
headers: getHeaders(mime),
|
||||||
body: data,
|
body: data,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -186,8 +187,13 @@ export async function uploadAvatar(id, data, mime) {
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarURL(id) {
|
export function getAvatarURL({ id, avatar_url }) {
|
||||||
return `${BASE_PATH}/client/${id}/avatar?access_token=${localStorage.accessToken}`
|
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)
|
export const putClient = client => defaultPut("client", client)
|
||||||
|
|
|
@ -31,7 +31,7 @@ const ClientListEntry = ({ entry }) => {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
|
<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>
|
<span className="displayname">{entry.displayname || entry.id}</span>
|
||||||
<ChevronRight/>
|
<ChevronRight/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
@ -129,7 +129,7 @@ class Client extends BaseMainView {
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
|
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
|
||||||
${this.state.uploadingAvatar ? "uploading" : ""}`}>
|
${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"/>
|
<UploadButton className="upload"/>
|
||||||
<input className="file-selector" type="file" accept="image/png, image/jpeg"
|
<input className="file-selector" type="file" accept="image/png, image/jpeg"
|
||||||
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
|
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
|
||||||
|
|
|
@ -73,7 +73,7 @@ class Instance extends BaseMainView {
|
||||||
value: client.id,
|
value: client.id,
|
||||||
label: (
|
label: (
|
||||||
<div className="select-client">
|
<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>
|
<span className="displayname">{client.displayname || client.id}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in a new issue