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.
This commit is contained in:
parent
b48ac505fd
commit
468e4b6d55
8 changed files with 212 additions and 11 deletions
|
|
@ -12,3 +12,4 @@ watchdog
|
|||
sarge
|
||||
netifaces
|
||||
pylru
|
||||
rsa
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -221,3 +221,7 @@ class SlicerPlugin(Plugin):
|
|||
pass
|
||||
|
||||
|
||||
class AppPlugin(Plugin):
|
||||
def get_additional_apps(self):
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
120
src/octoprint/server/apps/__init__.py
Normal file
120
src/octoprint/server/apps/__init__.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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*:)" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue