From 468e4b6d55b5e0f62d3517b37de34c459380fc0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 14 Nov 2014 14:30:25 +0100 Subject: [PATCH] Support for a new type of API key In order to solve the initial handshake problem with apps, OctoPrint now supports so called app session keys which are basically API keys with a limited validity. Obtaining those keys is based on a handshake procedure backed by RSA signatures. OctoPrint needs to be aware of apps and their associated public keys (with the AppPlugin there exists a mechanism to add additional recognized apps by installing a plugin). Apps perform the handshake by first requesting a temporary key with very limited validity, then sending a message back to OctoPrint containing their id, version, the temporary key and a signature created with their private key over these three pieces of data. OctoPrint then tries to verify the signature and if successful unlocks the key to be used as a fully recognized API key. --- requirements.txt | 1 + src/octoprint/plugin/__init__.py | 3 +- src/octoprint/plugin/types.py | 4 + src/octoprint/server/__init__.py | 5 ++ src/octoprint/server/api/__init__.py | 14 ++- src/octoprint/server/apps/__init__.py | 120 ++++++++++++++++++++++++++ src/octoprint/server/util/flask.py | 73 ++++++++++++++++ src/octoprint/settings.py | 3 +- 8 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 src/octoprint/server/apps/__init__.py diff --git a/requirements.txt b/requirements.txt index ec212ee2..2a9f5c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ watchdog sarge netifaces pylru +rsa diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index a137ab71..402e00e9 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -29,7 +29,8 @@ def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_en AssetPlugin, BlueprintPlugin, EventHandlerPlugin, - SlicerPlugin] + SlicerPlugin, + AppPlugin] if plugin_entry_points is None: plugin_entry_points = "octoprint.plugin" if plugin_disabled_list is None: diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 28747317..42de77de 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -221,3 +221,7 @@ class SlicerPlugin(Plugin): pass +class AppPlugin(Plugin): + def get_additional_apps(self): + return [] + diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index d0d0a8e7..0de8e70f 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -34,6 +34,7 @@ userManager = None eventManager = None loginManager = None pluginManager = None +appSessionManager = None principals = Principal(app) admin_permission = Permission(RoleNeed("admin")) @@ -203,6 +204,7 @@ class Server(): global eventManager global loginManager global pluginManager + global appSessionManager global debug from tornado.ioloop import IOLoop @@ -227,6 +229,7 @@ class Server(): storage_managers[octoprint.filemanager.FileDestinations.LOCAL] = octoprint.filemanager.storage.LocalFileStorage(settings().getBaseFolder("uploads")) fileManager = octoprint.filemanager.FileManager(analysisQueue, slicingManager, initial_storage_managers=storage_managers) printer = Printer(fileManager, analysisQueue) + appSessionManager = util.flask.AppSessionManager() # configure additional template folders for jinja2 template_plugins = pluginManager.get_implementations(octoprint.plugin.TemplatePlugin) @@ -293,9 +296,11 @@ class Server(): app.debug = self._debug from octoprint.server.api import api + from octoprint.server.apps import apps # register API blueprint app.register_blueprint(api, url_prefix="/api") + app.register_blueprint(apps, url_prefix="/apps") # also register any blueprints defined in BlueprintPlugins octoprint.plugin.call_plugin(octoprint.plugin.types.BlueprintPlugin, diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py index 0f339b7a..fd9fd5e3 100644 --- a/src/octoprint/server/api/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -17,7 +17,7 @@ import octoprint.util as util import octoprint.users import octoprint.server import octoprint.plugin -from octoprint.server import admin_permission, NO_CONTENT, UI_API_KEY +from octoprint.server import admin_permission, NO_CONTENT, UI_API_KEY, appSessionManager from octoprint.settings import settings as s, valid_boolean_trues from octoprint.server.util import get_api_key, get_user_for_apikey from octoprint.server.util.flask import restricted_access @@ -90,6 +90,10 @@ def beforeApiRequests(): # global api key => continue regular request processing return + if appSessionManager.validate(apikey): + # app session key => continue regular request processing + return + user = get_user_for_apikey(apikey) if user is not None: # user specific api key => continue regular request processing @@ -182,14 +186,6 @@ def apiPrinterState(): return make_response(("/api/state has been deprecated, use /api/printer instead", 405, [])) -@api.route("/version", methods=["GET"]) -@restricted_access -def apiVersion(): - return jsonify({ - "server": octoprint.server.VERSION, - "api": octoprint.server.api.VERSION - }) - @api.route("/version", methods=["GET"]) @restricted_access def apiVersion(): diff --git a/src/octoprint/server/apps/__init__.py b/src/octoprint/server/apps/__init__.py new file mode 100644 index 00000000..c00c14ec --- /dev/null +++ b/src/octoprint/server/apps/__init__.py @@ -0,0 +1,120 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import rsa +from flask import Blueprint, request, make_response, jsonify + +import octoprint.server +import octoprint.plugin +from octoprint.settings import settings as s + +apps = Blueprint("apps", __name__) + +@apps.route("/auth", methods=["GET"]) +def getSessionKey(): + unverified_key, valid_until = octoprint.server.appSessionManager.create() + return jsonify(unverifiedKey=unverified_key, validUntil=valid_until) + +@apps.route("/auth", methods=["POST"]) +def verifySessionKey(): + if not "application/json" in request.headers["Content-Type"]: + return None, None, make_response("Expected content-type JSON", 400) + + data = request.json + for key in ("appid", "key", "_sig"): + if not key in data: + return make_response("Missing argument: {key}".format(key=key), 400) + + appid = str(data["appid"]) + if not "appversion" in data: + appversion = "any" + else: + appversion = str(data["appversion"]) + key = str(data["key"]) + + # calculate message that was signed + message = "{appid}:{appversion}:{key}".format(**locals()) + + # decode signature + import base64 + signature = data["_sig"] + signature = base64.decodestring("\n".join([signature[x:x+64] for x in range(0, len(signature), 64)])) + + # fetch and validate app information + lookup_key = appid + ":" + appversion + apps = _get_registered_apps() + if not lookup_key in apps or not apps[lookup_key]["enabled"] or not "pubkey" in apps[lookup_key]: + octoprint.server.appSessionManager.remove(key) + return make_response("Invalid app: {lookup_key}".format(lookup_key=lookup_key), 403) + + pubkey_string = apps[lookup_key]["pubkey"] + pubkey_string = "\n".join([pubkey_string[x:x+64] for x in range(0, len(pubkey_string), 64)]) + try: + pubkey = rsa.PublicKey.load_pkcs1("-----BEGIN RSA PUBLIC KEY-----\n" + pubkey_string + "\n-----END RSA PUBLIC KEY-----\n") + except: + octoprint.server.appSessionManager.remove(key) + return make_response("Invalid pubkey stored in server", 500) + + # verify signature + try: + rsa.verify(message, signature, pubkey) + except rsa.VerificationError: + octoprint.server.appSessionManager.remove(key) + return make_response("Invalid signature", 403) + + # generate new session key and return it + result = octoprint.server.appSessionManager.verify(key) + if not result: + return make_response("Invalid key or already verified", 403) + + verified_key, valid_until = result + return jsonify(key=verified_key, validUntil=valid_until) + +__registered_apps = None +def _get_registered_apps(): + global __registered_apps + + if __registered_apps is not None: + return __registered_apps + + apps = s().get(["api", "apps"], merged=True) + for app, app_data in apps.items(): + if not "enabled" in app_data: + apps[app]["enabled"] = True + + app_plugins = octoprint.server.pluginManager.get_implementations(octoprint.plugin.AppPlugin) + for name, plugin in app_plugins.items(): + additional_apps = plugin.get_additional_apps() + any_version_enabled = dict() + + for app_data in additional_apps: + id, version, pubkey = app_data + key = id + ":" + version + if key in apps: + continue + + if not id in any_version_enabled: + any_version_enabled[id] = False + + if version == "any": + any_version_enabled[id] = True + + apps[key] = dict( + pubkey=pubkey, + enabled=True + ) + + for id, enabled in any_version_enabled.items(): + if enabled: + continue + apps[id + ":any"] = dict( + pubkey=None, + enabled=False + ) + + __registered_apps = apps + return apps \ No newline at end of file diff --git a/src/octoprint/server/util/flask.py b/src/octoprint/server/util/flask.py index 414f5be5..ed664fb5 100644 --- a/src/octoprint/server/util/flask.py +++ b/src/octoprint/server/util/flask.py @@ -10,6 +10,10 @@ import flask import flask.ext.login import flask.ext.principal import functools +import time +import uuid +import threading +import logging from octoprint.settings import settings import octoprint.server @@ -122,6 +126,9 @@ def restricted_access(func, api_enabled=True): if apikey == settings().get(["api", "key"]): # master key was used user = octoprint.users.ApiUser() + elif octoprint.server.appSessionManager.validate(apikey): + # valid app session key was used + user = octoprint.users.ApiUser() else: # user key might have been used user = octoprint.server.userManager.findUser(apikey=apikey) @@ -151,4 +158,70 @@ def api_access(func): return decorated_view +class AppSessionManager(object): + + VALIDITY_UNVERIFIED = 1 * 60 # 1 minute + VALIDITY_VERIFIED = 2 * 60 * 60 # 2 hours + + def __init__(self): + self._sessions = dict() + self._oldest = None + self._mutex = threading.RLock() + + self._logger = logging.getLogger(__name__) + + def create(self): + self._clean_sessions() + + key = ''.join('%02X' % ord(z) for z in uuid.uuid4().bytes) + created = time.time() + valid_until = created + self.__class__.VALIDITY_UNVERIFIED + + with self._mutex: + self._sessions[key] = (created, False, valid_until) + return key, valid_until + + def remove(self, key): + with self._mutex: + if not key in self._sessions: + return + del self._sessions[key] + + def verify(self, key): + self._clean_sessions() + + if not key in self._sessions: + return False + + with self._mutex: + created, verified, _ = self._sessions[key] + if verified: + return False + + valid_until = created + self.__class__.VALIDITY_VERIFIED + self._sessions[key] = created, True, created + self.__class__.VALIDITY_VERIFIED + + return key, valid_until + + def validate(self, key): + self._clean_sessions() + return key in self._sessions and self._sessions[key][1] + + def _clean_sessions(self): + if self._oldest is not None and self._oldest > time.time(): + return + + with self._mutex: + self._oldest = None + for key, value in self._sessions.items(): + created, verified, valid_until = value + if not verified: + valid_until = created + self.__class__.VALIDITY_UNVERIFIED + + if valid_until < time.time(): + del self._sessions[key] + elif self._oldest is None or valid_until < self._oldest: + self._oldest = valid_until + + self._logger.debug("App sessions after cleanup: %r" % self._sessions) diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 05fe60dd..f76f51d2 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -154,7 +154,8 @@ default_settings = { "api": { "enabled": True, "key": None, - "allowCrossOrigin": False + "allowCrossOrigin": False, + "apps": {} }, "terminalFilters": [ { "name": "Suppress M105 requests/responses", "regex": "(Send: M105)|(Recv: ok T\d*:)" },