From 4cf041aaad7fdcce72c1aa4ace75bf908fa0ab6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Sat, 10 Aug 2013 21:59:05 +0200 Subject: [PATCH] Enforces a new first-run setup wizard for access control to be run and forbids running OctoPrint as root unless a special command option is supplied The dialog also informs about the risk of unauthorized strangers (mis)using the printer if an unsecured OctoPrint installation is made available on the internet. --- octoprint/server.py | 113 ++++++++++++++++++++++------- octoprint/settings.py | 5 +- octoprint/static/js/ui.js | 80 ++++++++++++++++++++ octoprint/templates/dialogs.jinja2 | 54 +++++++++++++- octoprint/templates/index.jinja2 | 1 + octoprint/users.py | 10 ++- run | 6 +- 7 files changed, 237 insertions(+), 32 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index 2601e89..0fc08c7 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -8,6 +8,8 @@ from flask import Flask, request, render_template, jsonify, send_from_directory, from flask.ext.login import LoginManager, login_user, logout_user, login_required, current_user from flask.ext.principal import Principal, Permission, RoleNeed, Identity, identity_changed, AnonymousIdentity, identity_loaded, UserNeed +from functools import wraps + import os import threading import logging, logging.config @@ -35,6 +37,7 @@ timelapse = None gcodeManager = None userManager = None eventManager = None +loginManager = None principals = Principal(app) admin_permission = Permission(RoleNeed("admin")) @@ -126,6 +129,26 @@ class PrinterStateConnection(tornadio2.SocketConnection): def _onMovieDone(self, event, payload): self.sendUpdateTrigger("timelapseFiles") +def restricted_access(func): + """ + If you decorate a view with this, it will ensure that first setup has been + done for OctoPrint's Access Control plus that any conditions of the + login_required decorator are met. + + If OctoPrint's Access Control has not been setup yet (indicated by the "firstRun" + flag from the settings being set to True and the userManager not indicating + that it's user database has been customized from default), the decorator + will cause a HTTP 403 status code to be returned by the decorated resource. + + Otherwise the result of calling login_required will be returned. + """ + @wraps(func) + def decorated_view(*args, **kwargs): + if settings().getBoolean(["server", "firstRun"]) and (userManager is None or not userManager.hasBeenCustomized()): + return make_response("OctoPrint isn't setup yet", 403) + return login_required(func)(*args, **kwargs) + return decorated_view + # Did attempt to make webserver an encapsulated class but ended up with __call__ failures @app.route("/") @@ -146,6 +169,7 @@ def index(): enableSystemMenu=settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0, enableAccessControl=userManager is not None, enableSdSupport=settings().get(["feature", "sdSupport"]), + firstRun=settings().getBoolean(["server", "firstRun"]) and (userManager is None or not userManager.hasBeenCustomized()), gitBranch=branch, gitCommit=commit ) @@ -157,7 +181,7 @@ def connectionOptions(): return jsonify(getConnectionOptions()) @app.route(BASEURL + "control/connection", methods=["POST"]) -@login_required +@restricted_access def connect(): if "command" in request.values.keys() and request.values["command"] == "connect": port = None @@ -180,7 +204,7 @@ def connect(): return jsonify(SUCCESS) @app.route(BASEURL + "control/command", methods=["POST"]) -@login_required +@restricted_access def printerCommand(): if "application/json" in request.headers["Content-Type"]: data = request.json @@ -204,7 +228,7 @@ def printerCommand(): return jsonify(SUCCESS) @app.route(BASEURL + "control/job", methods=["POST"]) -@login_required +@restricted_access def printJobControl(): if "command" in request.values.keys(): if request.values["command"] == "start": @@ -216,7 +240,7 @@ def printJobControl(): return jsonify(SUCCESS) @app.route(BASEURL + "control/temperature", methods=["POST"]) -@login_required +@restricted_access def setTargetTemperature(): if "temp" in request.values.keys(): # set target temperature @@ -231,7 +255,7 @@ def setTargetTemperature(): return jsonify(SUCCESS) @app.route(BASEURL + "control/jog", methods=["POST"]) -@login_required +@restricted_access def jog(): if not printer.isOperational() or printer.isPrinting(): # do not jog when a print job is running or we don't have a connection @@ -269,7 +293,7 @@ def getCustomControls(): return jsonify(controls=customControls) @app.route(BASEURL + "control/sd", methods=["POST"]) -@login_required +@restricted_access def sdCommand(): if not settings().getBoolean(["feature", "sdSupport"]) or not printer.isOperational() or printer.isPrinting(): return jsonify(SUCCESS) @@ -308,7 +332,7 @@ def readGcodeFile(filename): return send_from_directory(settings().getBaseFolder("uploads"), filename, as_attachment=True) @app.route(BASEURL + "gcodefiles/upload", methods=["POST"]) -@login_required +@restricted_access def uploadGcodeFile(): if "gcode_file" in request.files.keys(): file = request.files["gcode_file"] @@ -350,7 +374,7 @@ def uploadGcodeFile(): @app.route(BASEURL + "gcodefiles/load", methods=["POST"]) -@login_required +@restricted_access def loadGcodeFile(): if "filename" in request.values.keys(): printAfterLoading = False @@ -367,7 +391,7 @@ def loadGcodeFile(): return jsonify(SUCCESS) @app.route(BASEURL + "gcodefiles/delete", methods=["POST"]) -@login_required +@restricted_access def deleteGcodeFile(): if "filename" in request.values.keys(): filename = request.values["filename"] @@ -391,7 +415,7 @@ def deleteGcodeFile(): return readGcodeFiles() @app.route(BASEURL + "gcodefiles/refresh", methods=["POST"]) -@login_required +@restricted_access def refreshFiles(): printer.updateSdFiles() return jsonify(SUCCESS) @@ -478,7 +502,7 @@ def downloadTimelapse(filename): return send_from_directory(settings().getBaseFolder("timelapse"), filename, as_attachment=True) @app.route(BASEURL + "timelapse/", methods=["DELETE"]) -@login_required +@restricted_access def deleteTimelapse(filename): if util.isAllowedFile(filename, set(["mpg"])): secure = os.path.join(settings().getBaseFolder("timelapse"), secure_filename(filename)) @@ -487,7 +511,7 @@ def deleteTimelapse(filename): return getTimelapseData() @app.route(BASEURL + "timelapse", methods=["POST"]) -@login_required +@restricted_access def setTimelapseConfig(): global timelapse @@ -578,7 +602,7 @@ def getSettings(): }) @app.route(BASEURL + "settings", methods=["POST"]) -@login_required +@restricted_access @admin_permission.require(403) def setSettings(): if "application/json" in request.headers["Content-Type"]: @@ -649,10 +673,36 @@ def setSettings(): return getSettings() +@app.route(BASEURL + "setup", methods=["POST"]) +def firstRunSetup(): + global userManager + + if not settings().getBoolean(["server", "firstRun"]): + abort(403) + + if "ac" in request.values.keys() and request.values["ac"] in valid_boolean_trues and \ + "user" in request.values.keys() and "pass1" in request.values.keys() and \ + "pass2" in request.values.keys() and request.values["pass1"] == request.values["pass2"]: + # configure access control + settings().setBoolean(["accessControl", "enabled"], True) + userManager.addUser(request.values["user"], request.values["pass1"], True, ["user", "admin"]) + settings().setBoolean(["server", "firstRun"], False) + elif "ac" in request.values.keys() and not request.values["ac"] in valid_boolean_trues: + # disable access control + settings().setBoolean(["accessControl", "enabled"], False) + settings().setBoolean(["server", "firstRun"], False) + + userManager = None + loginManager.anonymous_user = users.DummyUser + principals.identity_loaders.appendleft(users.dummy_identity_loader) + + settings().save() + return jsonify(SUCCESS) + #~~ user settings @app.route(BASEURL + "users", methods=["GET"]) -@login_required +@restricted_access @admin_permission.require(403) def getUsers(): if userManager is None: @@ -661,7 +711,7 @@ def getUsers(): return jsonify({"users": userManager.getAllUsers()}) @app.route(BASEURL + "users", methods=["POST"]) -@login_required +@restricted_access @admin_permission.require(403) def addUser(): if userManager is None: @@ -685,7 +735,7 @@ def addUser(): return getUsers() @app.route(BASEURL + "users/", methods=["GET"]) -@login_required +@restricted_access def getUser(username): if userManager is None: return jsonify(SUCCESS) @@ -700,7 +750,7 @@ def getUser(username): abort(403) @app.route(BASEURL + "users/", methods=["PUT"]) -@login_required +@restricted_access @admin_permission.require(403) def updateUser(username): if userManager is None: @@ -725,7 +775,7 @@ def updateUser(username): abort(404) @app.route(BASEURL + "users/", methods=["DELETE"]) -@login_required +@restricted_access @admin_permission.require(http_exception=403) def removeUser(username): if userManager is None: @@ -738,7 +788,7 @@ def removeUser(username): abort(404) @app.route(BASEURL + "users//password", methods=["PUT"]) -@login_required +@restricted_access def changePasswordForUser(username): if userManager is None: return jsonify(SUCCESS) @@ -758,7 +808,7 @@ def changePasswordForUser(username): #~~ system control @app.route(BASEURL + "system", methods=["POST"]) -@login_required +@restricted_access @admin_permission.require(403) def performSystemAction(): logger = logging.getLogger(__name__) @@ -806,7 +856,7 @@ def login(): return jsonify(SUCCESS) @app.route(BASEURL + "logout", methods=["POST"]) -@login_required +@restricted_access def logout(): # Remove session keys set by Flask-Principal for key in ('identity.id', 'identity.auth_type'): @@ -836,20 +886,25 @@ def load_user(id): #~~ startup code class Server(): - def __init__(self, configfile=None, basedir=None, host="0.0.0.0", port=5000, debug=False): + def __init__(self, configfile=None, basedir=None, host="0.0.0.0", port=5000, debug=False, allowRoot=False): self._configfile = configfile self._basedir = basedir self._host = host self._port = port self._debug = debug + self._allowRoot = allowRoot def run(self): + if not self._allowRoot: + self._checkForRoot() + # Global as I can't work out a way to get it into PrinterStateConnection global printer global gcodeManager global userManager global eventManager + global loginManager from tornado.wsgi import WSGIContainer from tornado.httpserver import HTTPServer @@ -882,13 +937,13 @@ class Server(): logger.exception("Could not instantiate user manager %s, will run with accessControl disabled!" % userManagerName) app.secret_key = "k3PuVYgtxNm8DXKKTw2nWmFQQun9qceV" - login_manager = LoginManager() - login_manager.session_protection = "strong" - login_manager.user_callback = load_user + loginManager = LoginManager() + loginManager.session_protection = "strong" + loginManager.user_callback = load_user if userManager is None: - login_manager.anonymous_user = users.DummyUser + loginManager.anonymous_user = users.DummyUser principals.identity_loaders.appendleft(users.dummy_identity_loader) - login_manager.init_app(app) + loginManager.init_app(app) if self._host is None: self._host = settings().get(["server", "host"]) @@ -918,6 +973,10 @@ class Server(): global printer, gcodeManager, userManager, eventManager return PrinterStateConnection(printer, gcodeManager, userManager, eventManager, session, endpoint) + def _checkForRoot(self): + if "geteuid" in dir(os) and os.geteuid() == 0: + exit("You should not run OctoPrint as root!") + def _initSettings(self, configfile, basedir): s = settings(init=True, basedir=basedir, configfile=configfile) diff --git a/octoprint/settings.py b/octoprint/settings.py index d7979bd..cdc7cc4 100644 --- a/octoprint/settings.py +++ b/octoprint/settings.py @@ -36,7 +36,8 @@ default_settings = { }, "server": { "host": "0.0.0.0", - "port": 5000 + "port": 5000, + "firstRun": True }, "webcam": { "stream": None, @@ -85,7 +86,7 @@ default_settings = { "actions": [] }, "accessControl": { - "enabled": False, + "enabled": True, "userManager": "octoprint.users.FilebasedUserManager", "userfile": None }, diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index c309f4f..470b34f 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -1946,6 +1946,80 @@ function AppearanceViewModel(settingsViewModel) { }) } +function FirstRunViewModel() { + var self = this; + + self.username = ko.observable(undefined); + self.password = ko.observable(undefined); + self.confirmedPassword = ko.observable(undefined); + + self.passwordMismatch = ko.computed(function() { + return self.password() != self.confirmedPassword(); + }); + + self.validUsername = ko.computed(function() { + return self.username() && self.username().trim() != ""; + }); + + self.validPassword = ko.computed(function() { + return self.password() && self.password().trim() != ""; + }); + + self.validData = ko.computed(function() { + return !self.passwordMismatch() && self.validUsername() && self.validPassword(); + }); + + self.keepAccessControl = function() { + var data = { + "ac": true, + "user": self.username(), + "pass1": self.password(), + "pass2": self.confirmedPassword() + }; + self._sendData(data); + }; + + self.disableAccessControl = function() { + $("#confirmation_dialog .confirmation_dialog_message").html("If you disable Access Control and your OctoPrint " + + "installation is accessible from the internet, your printer will be accessible by everyone - " + + "that also includes the bad guys!"); + $("#confirmation_dialog .confirmation_dialog_acknowledge").click(function(e) { + e.preventDefault(); + $("#confirmation_dialog").modal("hide"); + + var data = { + "ac": false + }; + self._sendData(data, function() { + // if the user indeed disables access control, we'll need to reload the page for this to take effect + location.reload(); + }); + }); + $("#confirmation_dialog").modal("show"); + }; + + self._sendData = function(data, callback) { + $.ajax({ + url: AJAX_BASEURL + "setup", + type: "POST", + dataType: "json", + data: data, + success: function() { + self.closeDialog(); + if (callback) callback(); + } + }); + } + + self.showDialog = function() { + $("#first_run_dialog").modal("show"); + } + + self.closeDialog = function() { + $("#first_run_dialog").modal("hide"); + } +} + $(function() { //~~ View models @@ -2242,6 +2316,12 @@ $(function() { $(document).bind("drop dragover", function (e) { e.preventDefault(); }); + + if (CONFIG_FIRST_RUN) { + var firstRunViewModel = new FirstRunViewModel(); + ko.applyBindings(firstRunViewModel, document.getElementById("first_run_dialog")); + firstRunViewModel.showDialog(); + } } ); diff --git a/octoprint/templates/dialogs.jinja2 b/octoprint/templates/dialogs.jinja2 index b9244f0..bab7fda 100644 --- a/octoprint/templates/dialogs.jinja2 +++ b/octoprint/templates/dialogs.jinja2 @@ -1,4 +1,4 @@ -
+
@@ -45,4 +45,56 @@ Proceed
+
+ + \ No newline at end of file diff --git a/octoprint/templates/index.jinja2 b/octoprint/templates/index.jinja2 index 936d836..ec0682b 100644 --- a/octoprint/templates/index.jinja2 +++ b/octoprint/templates/index.jinja2 @@ -25,6 +25,7 @@ var CONFIG_WEBCAM_STREAM = "{{ webcamStream }}"; var CONFIG_ACCESS_CONTROL = {% if enableAccessControl -%} true; {% else %} false; {%- endif %} var CONFIG_SD_SUPPORT = {% if enableSdSupport -%} true; {% else %} false; {%- endif %} + var CONFIG_FIRST_RUN = {% if firstRun -%} true; {% else %} false; {%- endif %} var WEB_SOCKET_SWF_LOCATION = "{{ url_for('static', filename='js/socket.io/WebSocketMain.swf') }}"; var WEB_SOCKET_DEBUG = true; diff --git a/octoprint/users.py b/octoprint/users.py index d7cb453..b959f93 100644 --- a/octoprint/users.py +++ b/octoprint/users.py @@ -44,6 +44,9 @@ class UserManager(object): def getAllUsers(self): return [] + def hasBeenCustomized(self): + return False + ##~~ FilebasedUserManager, takes available users from users.yaml file class FilebasedUserManager(UserManager): @@ -57,17 +60,19 @@ class FilebasedUserManager(UserManager): self._users = {} self._dirty = False + self._customized = None self._load() def _load(self): if os.path.exists(self._userfile) and os.path.isfile(self._userfile): + self._customized = True with open(self._userfile, "r") as f: data = yaml.safe_load(f) for name in data.keys(): attributes = data[name] self._users[name] = User(name, attributes["password"], attributes["active"], attributes["roles"]) else: - self._users["admin"] = User("admin", "7557160613d5258f883014a7c3c0428de53040fc152b1791f1cc04a62b428c0c2a9c46ed330cdce9689353ab7a5352ba2b2ceb459b96e9c8ed7d0cb0b2c0c076", True, ["user", "admin"]) + self._customized = False def _save(self, force=False): if not self._dirty and not force: @@ -169,6 +174,9 @@ class FilebasedUserManager(UserManager): def getAllUsers(self): return map(lambda x: x.asDict(), self._users.values()) + def hasBeenCustomized(self): + return self._customized + ##~~ Exceptions class UserAlreadyExists(Exception): diff --git a/run b/run index 9c11c3b..ca1ec2f 100755 --- a/run +++ b/run @@ -39,6 +39,10 @@ def main(): help="Daemonize/control daemonized OctoPrint instance (only supported under Linux right now)") parser.add_argument("--pid", action="store", type=str, dest="pidfile", default="/tmp/octoprint.pid", help="Pidfile to use for daemonizing, defaults to /tmp/octoprint.pid") + + parser.add_argument("--iknowwhatimdoing", action="store_true", dest="allowRoot", + help="Allow OctoPrint to run as user root") + args = parser.parse_args() if args.daemon: @@ -54,7 +58,7 @@ def main(): elif "restart" == args.daemon: daemon.restart() else: - octoprint = Server(args.config, args.basedir, args.host, args.port, args.debug) + octoprint = Server(args.config, args.basedir, args.host, args.port, args.debug, args.allowRoot) octoprint.run() if __name__ == "__main__":