More work on the authentication infrastructure

This commit is contained in:
Kovid Goyal 2015-11-05 12:01:16 +05:30
parent bdab1296a5
commit 08f6eb1c1f
4 changed files with 82 additions and 42 deletions

View File

@ -14,6 +14,7 @@ from threading import Lock
from calibre.db.cache import Cache from calibre.db.cache import Cache
from calibre.db.legacy import create_backend, LibraryDatabase from calibre.db.legacy import create_backend, LibraryDatabase
from calibre.srv.auth import AuthController
from calibre.srv.routes import Router from calibre.srv.routes import Router
from calibre.srv.users import UserManager from calibre.srv.users import UserManager
from calibre.utils.date import utcnow from calibre.utils.date import utcnow
@ -86,7 +87,7 @@ class Context(object):
self.library_broker = LibraryBroker(libraries) self.library_broker = LibraryBroker(libraries)
self.testing = testing self.testing = testing
self.lock = Lock() self.lock = Lock()
self.user_manager = UserManager() self.user_manager = UserManager(opts.userdb)
def init_session(self, endpoint, data): def init_session(self, endpoint, data):
pass pass
@ -144,7 +145,13 @@ class Context(object):
class Handler(object): class Handler(object):
def __init__(self, libraries, opts, testing=False): 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'): for module in ('content', 'ajax', 'code'):
module = import_module('calibre.srv.' + module) module = import_module('calibre.srv.' + module)
self.router.load_routes(vars(module).itervalues()) self.router.load_routes(vars(module).itervalues())
@ -154,6 +161,8 @@ class Handler(object):
def set_log(self, log): def set_log(self, log):
self.router.ctx.log = log self.router.ctx.log = log
if self.auth_controller is not None:
self.auth_controller.log = log
def close(self): def close(self):
self.router.ctx.library_broker.close() self.router.ctx.library_broker.close()

View File

@ -92,6 +92,22 @@ raw_options = (
'max_log_size', 20, 'max_log_size', 20,
'The maximum size of log files, generated by the server. When the log becomes larger' 'The maximum size of log files, generated by the server. When the log becomes larger'
' than this size, it is automatically rotated.', ' 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 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): for shortdoc, name, default, doc in grouper(4, raw_options):
choices = None choices = None
if isinstance(default, Choices): if isinstance(default, Choices):
choices = default choices = sorted(default)
default = default.default default = default.default
options.append(Option(name, default, doc, shortdoc, choices)) options.append(Option(name, default, doc, shortdoc, choices))
options = OrderedDict([(o.name, o) for o in sorted(options, key=attrgetter('name'))]) options = OrderedDict([(o.name, o) for o in sorted(options, key=attrgetter('name'))])

View File

@ -5,62 +5,71 @@
from __future__ import (unicode_literals, division, absolute_import, from __future__ import (unicode_literals, division, absolute_import,
print_function) print_function)
import os, json import os, json
from threading import Lock
import apsw import apsw
from calibre.constants import config_dir from calibre.constants import config_dir
from calibre.utils.config import to_json, from_json from calibre.utils.config import to_json, from_json
class UserManager(object): class UserManager(object):
lock = Lock()
@property @property
def conn(self): def conn(self):
if self._conn is None: if self._conn is None:
self._conn = apsw.Connection(self.path) self._conn = apsw.Connection(self.path)
c = self._conn.cursor() with self._conn:
uv = next(c.execute('PRAGMA foreign_keys = ON; PRAGMA user_version'))[0] c = self._conn.cursor()
if uv == 0: uv = next(c.execute('PRAGMA user_version'))[0]
c.execute(''' if uv == 0:
CREATE TABLE users ( # We have to store the unhashed password, since the digest
id INTEGER PRIMARY KEY, # auth scheme requires it.
name TEXT NOT NULL, # timestamp stores the ISO 8601 creation timestamp in UTC.
salt TEXT NOT NULL, c.execute('''
hashed_pw TEXT NOT NULL, CREATE TABLE users (
hash_type TEXT NOT NULL, id INTEGER PRIMARY KEY,
creation_date INTEGER NOT NULL, name TEXT NOT NULL,
UNIQUE(name) 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 ( PRAGMA user_version=1;
id INTEGER PRIMARY KEY, ''')
user INTEGER NOT NULL, c.close()
data TEXT NOT NULL,
UNIQUE(user),
FOREIGN KEY user REFERENCES users.id
);
PRAGMA user_version=1; def __init__(self, path=None):
''') self.path = os.path.join(config_dir, 'server-users.sqlite') if path is None else path
c.close()
def __init__(self):
self.path = os.path.join(config_dir, 'server-users.sqlite')
self._conn = None self._conn = None
def get_session_data(self, username): def get_session_data(self, username):
for data, in self.conn.cursor().execute( with self.lock:
'SELECT data FROM session_data INNER JOIN users ON (session_data.user = users.id) WHERE users.name=?', (username,)): for data, in self.conn.cursor().execute(
try: 'SELECT data FROM users WHERE name=?', (username,)):
return json.loads(data, object_hook=from_json) try:
except Exception: return json.loads(data, object_hook=from_json)
pass except Exception:
return {} break
return {}
def set_session_data(self, username, data): def set_session_data(self, username, data):
conn = self.conn with self.lock:
c = conn.cursor() conn = self.conn
for user_id, in c.execute('SELECT id FROM users WHERE name=?', (username,)): c = conn.cursor()
data = json.dumps(data, ensure_ascii=False, default=to_json) data = json.dumps(data, ensure_ascii=False, default=to_json)
c.execute('UPDATE session_data SET data=? WHERE user=?', (data, user_id)) if isinstance(data, bytes):
if not conn.changes(): data = data.decode('utf-8')
c.execute('INSERT INTO session_data (data,user) VALUES (?,?)', (data, user_id)) 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

View File

@ -46,3 +46,9 @@ def ajax(path, on_complete, on_progress=None, bypass_cache=True, method='GET', q
xhr.open(method, path) xhr.open(method, path)
return xhr 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)