diff --git a/src/calibre/srv/handler.py b/src/calibre/srv/handler.py index 6d0f68a01b..23e0d4b4e7 100644 --- a/src/calibre/srv/handler.py +++ b/src/calibre/srv/handler.py @@ -14,6 +14,7 @@ from threading import Lock from calibre.db.cache import Cache from calibre.db.legacy import create_backend, LibraryDatabase +from calibre.srv.auth import AuthController from calibre.srv.routes import Router from calibre.srv.users import UserManager from calibre.utils.date import utcnow @@ -86,7 +87,7 @@ class Context(object): self.library_broker = LibraryBroker(libraries) self.testing = testing self.lock = Lock() - self.user_manager = UserManager() + self.user_manager = UserManager(opts.userdb) def init_session(self, endpoint, data): pass @@ -144,7 +145,13 @@ class Context(object): class Handler(object): def __init__(self, libraries, opts, testing=False): - self.router = Router(ctx=Context(libraries, opts, testing=testing), url_prefix=opts.url_prefix) + ctx = Context(libraries, opts, testing=testing) + self.auth_controller = None + if opts.auth: + has_ssl = opts.ssl_certfile is not None and opts.ssl_keyfile is not None + prefer_basic_auth = {'auto':has_ssl, 'basic':True}.get(opts.auth_mode, 'digest') + self.auth_controller = AuthController(user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth) + self.router = Router(ctx=ctx, url_prefix=opts.url_prefix, auth_controller=self.auth_controller) for module in ('content', 'ajax', 'code'): module = import_module('calibre.srv.' + module) self.router.load_routes(vars(module).itervalues()) @@ -154,6 +161,8 @@ class Handler(object): def set_log(self, log): self.router.ctx.log = log + if self.auth_controller is not None: + self.auth_controller.log = log def close(self): self.router.ctx.library_broker.close() diff --git a/src/calibre/srv/opts.py b/src/calibre/srv/opts.py index 05a2a1cd5b..b517c040d7 100644 --- a/src/calibre/srv/opts.py +++ b/src/calibre/srv/opts.py @@ -92,6 +92,22 @@ raw_options = ( 'max_log_size', 20, 'The maximum size of log files, generated by the server. When the log becomes larger' ' than this size, it is automatically rotated.', + + 'Enable/disable password based authentication to access the server', + 'auth', False, + 'By default, the server is unrestricted, allowing anyone to access it. You can' + ' restrict access to predefined users with this option.', + + 'Path to user database', + 'userdb', None, + 'Path to a file in which to store the user and password information. By default a' + ' file in the calibre configuration directory is used.', + + 'Choose the type of authentication used', + 'auth_mode', Choices('auto', 'basic', 'digest'), + 'Set the HTTP authentication mode used by the server. Set to "basic" is you are' + ' putting this server behind an SSL proxy. Otherwise, leave it as "auto", which' + ' will use "basic" if SSL is configured otherwise it will use "digest".', ) assert len(raw_options) % 4 == 0 @@ -105,7 +121,7 @@ def grouper(n, iterable, fillvalue=None): for shortdoc, name, default, doc in grouper(4, raw_options): choices = None if isinstance(default, Choices): - choices = default + choices = sorted(default) default = default.default options.append(Option(name, default, doc, shortdoc, choices)) options = OrderedDict([(o.name, o) for o in sorted(options, key=attrgetter('name'))]) diff --git a/src/calibre/srv/users.py b/src/calibre/srv/users.py index 7a5020abc7..f0ba681df2 100644 --- a/src/calibre/srv/users.py +++ b/src/calibre/srv/users.py @@ -5,62 +5,71 @@ from __future__ import (unicode_literals, division, absolute_import, print_function) import os, json +from threading import Lock import apsw from calibre.constants import config_dir from calibre.utils.config import to_json, from_json + class UserManager(object): + lock = Lock() + @property def conn(self): if self._conn is None: self._conn = apsw.Connection(self.path) - c = self._conn.cursor() - uv = next(c.execute('PRAGMA foreign_keys = ON; PRAGMA user_version'))[0] - if uv == 0: - c.execute(''' -CREATE TABLE users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - salt TEXT NOT NULL, - hashed_pw TEXT NOT NULL, - hash_type TEXT NOT NULL, - creation_date INTEGER NOT NULL, - UNIQUE(name) -); + with self._conn: + c = self._conn.cursor() + uv = next(c.execute('PRAGMA user_version'))[0] + if uv == 0: + # We have to store the unhashed password, since the digest + # auth scheme requires it. + # timestamp stores the ISO 8601 creation timestamp in UTC. + c.execute(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + pw TEXT NOT NULL, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + session_data TEXT NOT NULL DEFAULT "{}", + restriction TEXT NOT NULL DEFAULT "", + misc_data TEXT NOT NULL DEFAULT "{}", + UNIQUE(name) + ); -CREATE TABLE session_data ( - id INTEGER PRIMARY KEY, - user INTEGER NOT NULL, - data TEXT NOT NULL, - UNIQUE(user), - FOREIGN KEY user REFERENCES users.id -); + PRAGMA user_version=1; + ''') + c.close() -PRAGMA user_version=1; -''') - c.close() - - def __init__(self): - self.path = os.path.join(config_dir, 'server-users.sqlite') + def __init__(self, path=None): + self.path = os.path.join(config_dir, 'server-users.sqlite') if path is None else path self._conn = None def get_session_data(self, username): - for data, in self.conn.cursor().execute( - 'SELECT data FROM session_data INNER JOIN users ON (session_data.user = users.id) WHERE users.name=?', (username,)): - try: - return json.loads(data, object_hook=from_json) - except Exception: - pass - return {} + with self.lock: + for data, in self.conn.cursor().execute( + 'SELECT data FROM users WHERE name=?', (username,)): + try: + return json.loads(data, object_hook=from_json) + except Exception: + break + return {} def set_session_data(self, username, data): - conn = self.conn - c = conn.cursor() - for user_id, in c.execute('SELECT id FROM users WHERE name=?', (username,)): + with self.lock: + conn = self.conn + c = conn.cursor() data = json.dumps(data, ensure_ascii=False, default=to_json) - c.execute('UPDATE session_data SET data=? WHERE user=?', (data, user_id)) - if not conn.changes(): - c.execute('INSERT INTO session_data (data,user) VALUES (?,?)', (data, user_id)) + if isinstance(data, bytes): + data = data.decode('utf-8') + c.execute('UPDATE users SET data=? WHERE name=?', (data, username)) + + def get(self, username): + ' Get password for user, or None if user does not exist ' + with self.lock: + for pw, in self.conn.cursor().execute( + 'SELECT pw FROM users WHERE name=?', (username,)): + return pw diff --git a/src/pyj/ajax.pyj b/src/pyj/ajax.pyj index 3cef6c33c7..cdf857f215 100644 --- a/src/pyj/ajax.pyj +++ b/src/pyj/ajax.pyj @@ -46,3 +46,9 @@ def ajax(path, on_complete, on_progress=None, bypass_cache=True, method='GET', q xhr.open(method, path) return xhr +# TODO: Implement AJAX based switch user by: +# 1) POST to a logout URL with an incorrect username and password +# 2) Have the server accept that incorrect username/password +# 3) Navigate to / which should cause the browser to re-prompt for username and password +# (only problem is login dialog will likely pre-show the wrong username) To workaround that, +# You can consider using a dedicated ajax based login form, see http://www.peej.co.uk/articles/http-auth-with-html-forms.html)