From 14fd0d6ac990d51f0329c7844f5169d7261cae72 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 31 Oct 2018 02:03:27 +0200 Subject: [PATCH] Finish plugin API and add basic login system --- .gitignore | 1 + example-config.yaml | 4 ++- maubot/__main__.py | 2 -- maubot/config.py | 35 +++++++++++++++++++---- maubot/instance.py | 26 +++++++++-------- maubot/loader/abc.py | 6 ++-- maubot/loader/zip.py | 4 ++- maubot/management/api/__init__.py | 11 +++++--- maubot/management/api/auth.py | 43 +++++++++++++++++++++++++++++ maubot/management/api/middleware.py | 23 +++++---------- maubot/management/api/plugin.py | 13 ++++----- maubot/management/api/responses.py | 41 ++++++++++++++++++++------- maubot/plugin_base.py | 7 ++++- maubot/server.py | 4 +++ requirements.txt | 1 + setup.py | 1 + 16 files changed, 160 insertions(+), 62 deletions(-) create mode 100644 maubot/management/api/auth.py diff --git a/.gitignore b/.gitignore index 9036bb6..d475bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ __pycache__ logs/ plugins/ +trash/ diff --git a/example-config.yaml b/example-config.yaml index 9bcf2bf..b3987f1 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -30,8 +30,10 @@ server: # Set to "generate" to generate and save a new token at startup. unshared_secret: generate +# 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: -- "@admin:example.com" + root: "" # Python logging configuration. # diff --git a/maubot/__main__.py b/maubot/__main__.py index dee04bc..6cf22a9 100644 --- a/maubot/__main__.py +++ b/maubot/__main__.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from sqlalchemy import orm -from time import time import sqlalchemy as sql import logging.config import argparse @@ -22,7 +21,6 @@ import asyncio import signal import copy import sys -import os from .config import Config from .db import Base, init as init_db diff --git a/maubot/config.py b/maubot/config.py index 699003c..cf39d00 100644 --- a/maubot/config.py +++ b/maubot/config.py @@ -15,9 +15,13 @@ # along with this program. If not, see . import random import string +import bcrypt +import re from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper +bcrypt_regex = re.compile(r"^\$2[ayb]\$.{56}$") + class Config(BaseFileConfig): @staticmethod @@ -27,16 +31,35 @@ class Config(BaseFileConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: base, copy, _ = helper copy("database") - copy("plugin_directories") - copy("plugin_db_directory") + copy("plugin_directories.upload") + copy("plugin_directories.load") + copy("plugin_directories.trash") + copy("plugin_directories.db") copy("server.hostname") copy("server.port") copy("server.listen") - copy("server.base_path") - shared_secret = self["server.shared_secret"] + copy("server.appservice_base_path") + shared_secret = self["server.unshared_secret"] if shared_secret is None or shared_secret == "generate": - base["server.shared_secret"] = self._new_token() + base["server.unshared_secret"] = self._new_token() else: - base["server.shared_secret"] = shared_secret + base["server.unshared_secret"] = shared_secret copy("admins") + for username, password in base["admins"].items(): + if password and not bcrypt_regex.match(password): + if password == "password": + password = self._new_token() + base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"), + bcrypt.gensalt()).decode("utf-8") copy("logging") + + def is_admin(self, user: str) -> bool: + return user == "root" or user in self["admins"] + + def check_password(self, user: str, passwd: str) -> bool: + if user == "root": + return False + passwd_hash = self["admins"].get(user, None) + if not passwd_hash: + return False + return bcrypt.checkpw(passwd.encode("utf-8"), passwd_hash.encode("utf-8")) diff --git a/maubot/instance.py b/maubot/instance.py index 0438713..71098fc 100644 --- a/maubot/instance.py +++ b/maubot/instance.py @@ -87,13 +87,6 @@ class PluginInstance: def load_config(self) -> CommentedMap: return yaml.load(self.db_instance.config) - def load_config_base(self) -> Optional[RecursiveDict[CommentedMap]]: - try: - base = self.loader.read_file("base-config.yaml") - return RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap) - except (FileNotFoundError, KeyError): - return None - def save_config(self, data: RecursiveDict[CommentedMap]) -> None: buf = io.StringIO() yaml.dump(data, buf) @@ -103,14 +96,23 @@ class PluginInstance: if not self.enabled: self.log.warning(f"Plugin disabled, not starting.") return - cls = self.loader.load() + cls = await self.loader.load() config_class = cls.get_config_class() if config_class: - self.config = config_class(self.load_config, self.load_config_base, - self.save_config) + try: + base = await self.loader.read_file("base-config.yaml") + base_file = RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap) + except (FileNotFoundError, KeyError): + base_file = None + self.config = config_class(self.load_config, lambda: base_file, self.save_config) self.plugin = cls(self.client.client, self.id, self.log, self.config, - self.mb_config["plugin_db_directory"]) - await self.plugin.start() + self.mb_config["plugin_directories.db"]) + try: + await self.plugin.start() + except Exception: + self.log.exception("Failed to start instance") + self.enabled = False + return self.running = True self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} " f"with user {self.client.id}") diff --git a/maubot/loader/abc.py b/maubot/loader/abc.py index 23a1de5..09d0f75 100644 --- a/maubot/loader/abc.py +++ b/maubot/loader/abc.py @@ -59,10 +59,12 @@ class PluginLoader(ABC): pass async def stop_instances(self) -> None: - await asyncio.gather([instance.stop() for instance in self.references if instance.running]) + await asyncio.gather(*[instance.stop() for instance + in self.references if instance.running]) async def start_instances(self) -> None: - await asyncio.gather([instance.start() for instance in self.references if instance.enabled]) + await asyncio.gather(*[instance.start() for instance + in self.references if instance.enabled]) @abstractmethod async def load(self) -> Type[PluginClass]: diff --git a/maubot/loader/zip.py b/maubot/loader/zip.py index d18894f..c811848 100644 --- a/maubot/loader/zip.py +++ b/maubot/loader/zip.py @@ -207,8 +207,10 @@ class ZippedPluginLoader(PluginLoader): self.log.debug(f"Loaded and imported plugin {self.id} from {self.path}") return plugin - async def reload(self) -> Type[PluginClass]: + async def reload(self, new_path: Optional[str] = None) -> Type[PluginClass]: await self.unload() + if new_path is not None: + self.path = new_path return await self.load(reset_cache=True) async def unload(self) -> None: diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py index dc08a5f..d3e2611 100644 --- a/maubot/management/api/__init__.py +++ b/maubot/management/api/__init__.py @@ -27,8 +27,9 @@ config: Config = None def is_valid_token(token: str) -> bool: data = verify_token(config["server.unshared_secret"], token) - user_id = data.get("user_id", None) - return user_id is not None and user_id in config["admins"] + if not data: + return False + return config.is_admin(data.get("user_id", None)) def create_token(user: UserID) -> str: @@ -40,7 +41,9 @@ def create_token(user: UserID) -> str: def init(cfg: Config, loop: AbstractEventLoop) -> web.Application: global config config = cfg - from .middleware import auth, error, log - app = web.Application(loop=loop, middlewares=[auth, log, error]) + from .middleware import auth, error + from .auth import web as _ + from .plugin import web as _ + app = web.Application(loop=loop, middlewares=[auth, error]) app.add_routes(routes) return app diff --git a/maubot/management/api/auth.py b/maubot/management/api/auth.py new file mode 100644 index 0000000..d08ca1c --- /dev/null +++ b/maubot/management/api/auth.py @@ -0,0 +1,43 @@ +# 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 +import json + +from . import routes, config, create_token +from .responses import ErrBadAuth, ErrBodyNotJSON + + +@routes.post("/login") +async def login(request: web.Request) -> web.Response: + try: + data = await request.json() + except json.JSONDecodeError: + return ErrBodyNotJSON + secret = data.get("secret") + if secret and config["server.unshared_secret"] == secret: + user = data.get("user") or "root" + return web.json_response({ + "token": create_token(user), + }) + + username = data.get("username") + password = data.get("password") + if config.check_password(username, password): + return web.json_response({ + "token": create_token(username), + }) + + return ErrBadAuth diff --git a/maubot/management/api/middleware.py b/maubot/management/api/middleware.py index f1b76ad..61e7097 100644 --- a/maubot/management/api/middleware.py +++ b/maubot/management/api/middleware.py @@ -15,25 +15,21 @@ # along with this program. If not, see . from typing import Callable, Awaitable from aiohttp import web -import logging -from .responses import ErrNoToken, ErrInvalidToken +from .responses import ErrNoToken, ErrInvalidToken, ErrPathNotFound, ErrMethodNotAllowed from . import is_valid_token Handler = Callable[[web.Request], Awaitable[web.Response]] -req_log = logging.getLogger("maubot.mgmt.request") -resp_log = logging.getLogger("maubot.mgmt.response") - @web.middleware async def auth(request: web.Request, handler: Handler) -> web.Response: + if request.path.endswith("/login"): + return await handler(request) token = request.headers.get("Authorization", "") if not token or not token.startswith("Bearer "): - req_log.debug(f"Request missing auth: {request.remote} {request.method} {request.path}") return ErrNoToken if not is_valid_token(token[len("Bearer "):]): - req_log.debug(f"Request invalid auth: {request.remote} {request.method} {request.path}") return ErrInvalidToken return await handler(request) @@ -43,6 +39,10 @@ async def error(request: web.Request, handler: Handler) -> web.Response: try: return await handler(request) except web.HTTPException as ex: + if ex.status_code == 404: + return ErrPathNotFound + elif ex.status_code == 405: + return ErrMethodNotAllowed return web.json_response({ "error": f"Unhandled HTTP {ex.status}", "errcode": f"unhandled_http_{ex.status}", @@ -56,12 +56,3 @@ def get_req_no(): global req_no req_no += 1 return req_no - - -@web.middleware -async def log(request: web.Request, handler: Handler) -> web.Response: - local_req_no = get_req_no() - req_log.info(f"Request {local_req_no}: {request.remote} {request.method} {request.path}") - resp = await handler(request) - resp_log.info(f"Responded to {local_req_no} from {request.remote}: {resp}") - return resp diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py index 7345bb3..644c158 100644 --- a/maubot/management/api/plugin.py +++ b/maubot/management/api/plugin.py @@ -92,25 +92,24 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes, if plugin.version in filename: filename = filename.replace(plugin.version, new_version) else: - filename = filename.rstrip(".mbp") + new_version + ".mbp" + filename = filename.rstrip(".mbp") + filename = f"{filename}-v{new_version}.mbp" path = os.path.join(dirname, filename) with open(path, "wb") as p: p.write(content) old_path = plugin.path - plugin.path = path await plugin.stop_instances() try: - await plugin.reload() + await plugin.reload(new_path=path) except MaubotZipImportError as e: - plugin.path = old_path try: - await plugin.reload() + await plugin.reload(new_path=old_path) + await plugin.start_instances() except MaubotZipImportError: pass - await plugin.start_instances() return plugin_import_error(str(e), traceback.format_exc()) await plugin.start_instances() - ZippedPluginLoader.trash(plugin.path, reason="update") + ZippedPluginLoader.trash(old_path, reason="update") return RespOK diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py index d328cc2..8b3d1c0 100644 --- a/maubot/management/api/responses.py +++ b/maubot/management/api/responses.py @@ -13,27 +13,48 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from http import HTTPStatus from aiohttp import web +ErrBadAuth = web.json_response({ + "error": "Invalid username or password", + "errcode": "invalid_auth", +}, status=HTTPStatus.UNAUTHORIZED) + ErrNoToken = web.json_response({ "error": "Authorization token missing", "errcode": "auth_token_missing", -}, status=web.HTTPUnauthorized) +}, status=HTTPStatus.UNAUTHORIZED) ErrInvalidToken = web.json_response({ "error": "Invalid authorization token", "errcode": "auth_token_invalid", -}, status=web.HTTPUnauthorized) +}, status=HTTPStatus.UNAUTHORIZED) ErrPluginNotFound = web.json_response({ "error": "Plugin not found", "errcode": "plugin_not_found", -}, status=web.HTTPNotFound) +}, status=HTTPStatus.NOT_FOUND) + +ErrPathNotFound = web.json_response({ + "error": "Resource not found", + "errcode": "resource_not_found", +}, status=HTTPStatus.NOT_FOUND) + +ErrMethodNotAllowed = web.json_response({ + "error": "Method not allowed", + "errcode": "method_not_allowed", +}, status=HTTPStatus.METHOD_NOT_ALLOWED) ErrPluginInUse = web.json_response({ "error": "Plugin instances of this type still exist", "errcode": "plugin_in_use", -}, status=web.HTTPPreconditionFailed) +}, status=HTTPStatus.PRECONDITION_FAILED) + +ErrBodyNotJSON = web.json_response({ + "error": "Request body is not JSON", + "errcode": "body_not_json", +}, status=HTTPStatus.BAD_REQUEST) def plugin_import_error(error: str, stacktrace: str) -> web.Response: @@ -41,7 +62,7 @@ def plugin_import_error(error: str, stacktrace: str) -> web.Response: "error": error, "stacktrace": stacktrace, "errcode": "plugin_invalid", - }, status=web.HTTPBadRequest) + }, status=HTTPStatus.BAD_REQUEST) def plugin_reload_error(error: str, stacktrace: str) -> web.Response: @@ -49,21 +70,21 @@ def plugin_reload_error(error: str, stacktrace: str) -> web.Response: "error": error, "stacktrace": stacktrace, "errcode": "plugin_reload_fail", - }, status=web.HTTPInternalServerError) + }, status=HTTPStatus.INTERNAL_SERVER_ERROR) ErrUnsupportedPluginLoader = web.json_response({ "error": "Existing plugin with same ID uses unsupported plugin loader", "errcode": "unsupported_plugin_loader", -}, status=web.HTTPBadRequest) +}, status=HTTPStatus.BAD_REQUEST) ErrNotImplemented = web.json_response({ "error": "Not implemented", "errcode": "not_implemented", -}, status=web.HTTPNotImplemented) +}, status=HTTPStatus.NOT_IMPLEMENTED) RespOK = web.json_response({ "success": True, -}, status=web.HTTPOk) +}, status=HTTPStatus.OK) -RespDeleted = web.Response(status=web.HTTPNoContent) +RespDeleted = web.Response(status=HTTPStatus.NO_CONTENT) diff --git a/maubot/plugin_base.py b/maubot/plugin_base.py index 08d1211..18620c6 100644 --- a/maubot/plugin_base.py +++ b/maubot/plugin_base.py @@ -27,6 +27,9 @@ if TYPE_CHECKING: from mautrix.util.config import BaseProxyConfig +DatabaseNotConfigured = ValueError("A database for this maubot instance has not been configured.") + + class Plugin(ABC): client: 'MaubotMatrixClient' id: str @@ -41,7 +44,9 @@ class Plugin(ABC): self.config = config self.__db_base_path = db_base_path - def request_db_engine(self) -> Engine: + def request_db_engine(self) -> Optional[Engine]: + if not self.__db_base_path: + raise DatabaseNotConfigured return sql.create_engine(f"sqlite:///{os.path.join(self.__db_base_path, self.id)}.db") def set_command_spec(self, spec: 'CommandSpec') -> None: diff --git a/maubot/server.py b/maubot/server.py index 4bd7bd2..501677a 100644 --- a/maubot/server.py +++ b/maubot/server.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from aiohttp import web +import logging import asyncio from mautrix.api import PathBuilder, Method @@ -23,6 +24,8 @@ from .__meta__ import __version__ class MaubotServer: + log: logging.Logger = logging.getLogger("maubot.server") + def __init__(self, config: Config, management: web.Application, loop: asyncio.AbstractEventLoop) -> None: self.loop = loop or asyncio.get_event_loop() @@ -45,6 +48,7 @@ class MaubotServer: await self.runner.setup() site = web.TCPSite(self.runner, self.config["server.hostname"], self.config["server.port"]) await site.start() + self.log.info(f"Listening on {site.name}") async def stop(self) -> None: await self.runner.cleanup() diff --git a/requirements.txt b/requirements.txt index f8671cd..2a918f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ alembic commonmark ruamel.yaml attrs +bcrypt diff --git a/setup.py b/setup.py index a546b06..0d8dad0 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ setuptools.setup( "commonmark>=0.8.1,<1", "ruamel.yaml>=0.15.35,<0.16", "attrs>=18.1.0,<19", + "bcrypt>=3.1.4,<4", ], classifiers=[