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.
master
Gina Häußge 2013-08-10 21:59:05 +02:00
parent 3b3bb36377
commit 4cf041aaad
7 changed files with 237 additions and 32 deletions

View File

@ -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/<filename>", 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/<username>", 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/<username>", 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/<username>", 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/<username>/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)

View File

@ -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
},

View File

@ -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 <strong>and</strong> your OctoPrint " +
"installation is accessible from the internet, your printer <strong>will be accessible by everyone - " +
"that also includes the bad guys!</strong>");
$("#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();
}
}
);

View File

@ -1,4 +1,4 @@
<div id="offline_overlay">
<div id="offline_overlay" xmlns="http://www.w3.org/1999/html">
<div id="offline_overlay_background"></div>
<div id="offline_overlay_wrapper">
<div class="container">
@ -45,4 +45,56 @@
<a href="#" class="btn" data-dismiss="modal" aria-hidden="true">Cancel</a>
<a href="#" class="btn btn-danger confirmation_dialog_acknowledge">Proceed</a>
</div>
</div>
<div id="first_run_dialog" class="modal hide fade" data-backdrop="static" data-keyboard="false">
<div class="modal-header">
<h3><i class="icon-warning-sign"></i> Configure Access Control</h3>
</div>
<div class="modal-body">
<p>
OctoPrint by default now ships with Access Control enabled, meaning you won't be able to do anything with the
printer unless you login first as a configured user. This is to prevent strangers (possibly with
malicious intent) to gain access to printer for using it in such a way that it may be damaged or worse, in case
you make your OctoPrint installation accessible to the internet or other untrustworthy computer networks.
</p>
<div>
<p>
It looks like you haven't configured access control yet. Please set up the username and password for the
initial administrator account who will have full access to both the printer and OctoPrint's settings:
</p>
<form class="form-horizontal">
<div class="control-group" data-bind="css: {success: validUsername()}">
<label class="control-label" for="first_run_username">Username</label>
<div class="controls">
<input type="text" class="input-medium" data-bind="value: username, valueUpdate: 'afterkeydown'">
</div>
</div>
<div class="control-group" data-bind="css: {success: validPassword()}">
<label class="control-label" for="first_run_username">Password</label>
<div class="controls">
<input type="password" class="input-medium" data-bind="value: password, valueUpdate: 'afterkeydown'">
</div>
</div>
<div class="control-group" data-bind="css: {error: passwordMismatch(), success: validPassword() && !passwordMismatch()}">
<label class="control-label" for="first_run_username">Confirm Password</label>
<div class="controls">
<input type="password" class="input-medium" data-bind="value: confirmedPassword, valueUpdate: 'afterkeydown'">
<span class="help-inline" data-bind="visible: passwordMismatch()">Passwords don't match</span>
</div>
</div>
</form>
</div>
<p>
In case that your OctoPrint installation is only accessible from within a network only trustworthy people have
access to and you don't need Access Control for other reasons, you may alternatively disable Access Control
all together. You should really only do this if you are absolutely sure that only people you know and trust
will be able to connect to it. To reiterate, do <strong>NOT</strong> underestimate the risk of an unsecured
access to your printer!
</p>
</div>
<div class="modal-footer">
<a href="#" class="btn btn-danger" data-bind="click: disableAccessControl">Disable Access Control</a>
<a href="#" class="btn btn-primary" data-bind="click: keepAccessControl, enable: validData()">Keep Access Control Enabled</a>
</div>
</div>

View File

@ -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;

View File

@ -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):

6
run
View File

@ -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__":