CLI to manage user accounts for the new server

This commit is contained in:
Kovid Goyal 2015-11-05 18:45:25 +05:30
parent 44ebdeb176
commit 145523e08a
3 changed files with 186 additions and 34 deletions

View File

@ -110,6 +110,7 @@ raw_options = (
' will use "basic" if SSL is configured otherwise it will use "digest".', ' will use "basic" if SSL is configured otherwise it will use "digest".',
) )
assert len(raw_options) % 4 == 0 assert len(raw_options) % 4 == 0
# TODO: Mark these strings for translation, once you finalize the option set
options = [] options = []

View File

@ -8,8 +8,8 @@ __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import sys, os, signal import sys, os, signal
from calibre import as_unicode from calibre import as_unicode, prints
from calibre.constants import plugins, iswindows from calibre.constants import plugins, iswindows, preferred_encoding
from calibre.srv.errors import InvalidCredentials from calibre.srv.errors import InvalidCredentials
from calibre.srv.loop import ServerLoop from calibre.srv.loop import ServerLoop
from calibre.srv.bonjour import BonJour from calibre.srv.bonjour import BonJour
@ -74,9 +74,103 @@ class Server(object):
from calibre.utils.rapydscript import compile_srv from calibre.utils.rapydscript import compile_srv
compile_srv() compile_srv()
# Manage users CLI {{{
def manage_users(path=None):
from calibre.srv.users import UserManager
m = UserManager(path)
enc = getattr(sys.stdin, 'encoding', preferred_encoding) or preferred_encoding
def choice(question, choices, default=None, banner=''):
prints(banner)
for i, choice in enumerate(choices):
prints('%d)' % (i+1), choice)
print()
while True:
prompt = question + ' [1-%d]: ' % len(choices)
if default is not None:
prompt = question + ' [1-%d %s: %d]' % (len(choices), _('default'), default+1)
reply = raw_input(prompt)
if not reply and default is not None:
reply = str(default + 1)
if not reply:
raise SystemExit(0)
reply = reply.strip()
try:
num = int(reply) - 1
if not (0 <= num < len(choices)):
raise Exception('bad num')
return num
except Exception:
prints(_('%s is not a valid choice, try again') % reply)
def get_valid(prompt, invalidq=lambda x: None):
while True:
ans = raw_input(prompt + ': ').strip().decode(enc)
fail_message = invalidq(ans)
if fail_message is None:
return ans
prints(fail_message)
def get_valid_user():
prints(_('Existing user names:'))
users = sorted(m.all_user_names)
if not users:
raise SystemExit(_('There are no users, you must first add an user'))
prints(', '.join(users))
def validate(username):
if not m.has_user(username):
return _('The username %s does not exist') % username
return get_valid(_('Enter the username'), validate)
def get_pass(username):
while True:
from getpass import getpass
one = getpass(_('Enter the new password for %s: ') % username).decode(enc)
if not one:
prints(_('Empty passwords are not allowed'))
continue
two = getpass(_('Re-enter the new password for %s, to verify: ') % username).decode(enc)
if one != two:
prints(_('Passwords do not match'))
continue
msg = m.validate_password(one)
if msg is None:
return one
prints(msg)
def add_user():
username = get_valid(_('Enter the username'), m.validate_username)
pw = get_pass(username)
m.add_user(username, pw)
prints(_('User %s added successfully!') % username)
def remove_user():
un = get_valid_user()
if raw_input((_('Are you sure you want to remove the user %s?') % un) + ' [y/n]: ').decode(enc) != 'y':
raise SystemExit(0)
m.remove_user(un)
prints(_('User %s successfully removed!') % un)
def edit_user():
username = get_valid_user()
pw = get_pass(username)
m.change_password(username, pw)
prints(_('Password for %s successfully changed!') % username)
def show_password():
username = get_valid_user()
pw = m.get(username)
prints(_('Password for {0} is: {1}').format(username, pw))
{0:add_user, 1:edit_user, 2:remove_user, 3:show_password}[choice(_('What do you want to do?'), [
_('Add a new user'), _('Edit an existing user'), _('Remove a user'), _('Show the password for a user')])]()
# }}}
def create_option_parser(): def create_option_parser():
parser = opts_to_parser('%prog '+ _( parser=opts_to_parser('%prog '+ _(
'''[options] [path to library folder ...] '''[options] [path to library folder ...]
Start the calibre content server. The calibre content server Start the calibre content server. The calibre content server
@ -98,19 +192,30 @@ program will be used.
help=_('Automatically reload server when source code changes. Useful' help=_('Automatically reload server when source code changes. Useful'
' for development. You should also specify a small value for the' ' for development. You should also specify a small value for the'
' shutdown timeout.')) ' shutdown timeout.'))
parser.add_option(
'--manage-users', default=False, action='store_true',
help=_('Manage the database of users allowed to connect to this server.'
' See also the %s option.') % '--userdb')
return parser return parser
def main(args=sys.argv): def main(args=sys.argv):
opts, args = create_option_parser().parse_args(args) opts, args=create_option_parser().parse_args(args)
libraries = args[1:] if opts.manage_users:
try:
manage_users(opts.userdb)
except (KeyboardInterrupt, EOFError):
raise SystemExit(_('Interrupted by user'))
raise SystemExit(0)
libraries=args[1:]
for lib in libraries: for lib in libraries:
if not lib or not LibraryDatabase.exists_at(lib): if not lib or not LibraryDatabase.exists_at(lib):
raise SystemExit(_('There is no calibre library at: %s') % lib) raise SystemExit(_('There is no calibre library at: %s') % lib)
if not libraries: if not libraries:
if not prefs['library_path']: if not prefs['library_path']:
raise SystemExit(_('You must specify at least one calibre library')) raise SystemExit(_('You must specify at least one calibre library'))
libraries = [prefs['library_path']] libraries=[prefs['library_path']]
if opts.auto_reload: if opts.auto_reload:
if opts.daemonize: if opts.daemonize:
@ -121,9 +226,9 @@ def main(args=sys.argv):
return auto_reload(default_log) return auto_reload(default_log)
except NoAutoReload as e: except NoAutoReload as e:
raise SystemExit(e.message) raise SystemExit(e.message)
opts.auto_reload_port = int(os.environ.get('CALIBRE_AUTORELOAD_PORT', 0)) opts.auto_reload_port=int(os.environ.get('CALIBRE_AUTORELOAD_PORT', 0))
try: try:
server = Server(libraries, opts) server=Server(libraries, opts)
except InvalidCredentials as e: except InvalidCredentials as e:
raise SystemExit(e.message) raise SystemExit(e.message)
if opts.daemonize: if opts.daemonize:

View File

@ -4,8 +4,8 @@
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, re
from threading import Lock from threading import RLock
import apsw import apsw
@ -15,34 +15,37 @@ from calibre.utils.config import to_json, from_json
class UserManager(object): class UserManager(object):
lock = Lock() lock = RLock()
@property @property
def conn(self): def conn(self):
if self._conn is None: with self.lock:
self._conn = apsw.Connection(self.path) if self._conn is None:
with self._conn: self._conn = apsw.Connection(self.path)
c = self._conn.cursor() with self._conn:
uv = next(c.execute('PRAGMA user_version'))[0] c = self._conn.cursor()
if uv == 0: uv = next(c.execute('PRAGMA user_version'))[0]
# We have to store the unhashed password, since the digest if uv == 0:
# auth scheme requires it. # We have to store the unhashed password, since the digest
# timestamp stores the ISO 8601 creation timestamp in UTC. # auth scheme requires it.
c.execute(''' # timestamp stores the ISO 8601 creation timestamp in UTC.
CREATE TABLE users ( c.execute('''
id INTEGER PRIMARY KEY, CREATE TABLE users (
name TEXT NOT NULL, id INTEGER PRIMARY KEY,
pw TEXT NOT NULL, name TEXT NOT NULL,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP, pw TEXT NOT NULL,
session_data TEXT NOT NULL DEFAULT "{}", timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
restriction TEXT NOT NULL DEFAULT "", session_data TEXT NOT NULL DEFAULT "{}",
misc_data TEXT NOT NULL DEFAULT "{}", restriction TEXT NOT NULL DEFAULT "",
UNIQUE(name) readonly TEXT NOT NULL DEFAULT "n",
); misc_data TEXT NOT NULL DEFAULT "{}",
UNIQUE(name)
);
PRAGMA user_version=1; PRAGMA user_version=1;
''') ''')
c.close() c.close()
return self._conn
def __init__(self, path=None): def __init__(self, path=None):
self.path = os.path.join(config_dir, 'server-users.sqlite') if path is None else path self.path = os.path.join(config_dir, 'server-users.sqlite') if path is None else path
@ -73,3 +76,46 @@ class UserManager(object):
for pw, in self.conn.cursor().execute( for pw, in self.conn.cursor().execute(
'SELECT pw FROM users WHERE name=?', (username,)): 'SELECT pw FROM users WHERE name=?', (username,)):
return pw return pw
def has_user(self, username):
return self.get(username) is not None
def validate_username(self, username):
if self.has_user(username):
return _('The username %s already exists') % username
if re.sub(r'[a-zA-Z0-9 ]', '', username):
return _('For maximum compatibility you should use only the letters A-Z, the numbers 0-9 and spaces in the username')
def validate_password(self, pw):
try:
pw = pw.encode('ascii', 'strict')
except ValueError:
return _('The password must contain only ASCII (English) characters and symbols')
def add_user(self, username, pw, restriction='', readonly=False):
with self.lock:
msg = self.validate_username(username) or self.validate_password(pw)
if msg is not None:
raise ValueError(msg)
self.conn.cursor().execute(
'INSERT INTO users (name, pw, restriction, readonly) VALUES (?, ?, ?, ?)',
(username, pw, restriction, ('y' if readonly else 'n')))
def remove_user(self, username):
with self.lock:
self.conn.cursor().execute('DELETE FROM users WHERE name=?', (username,))
return self.conn.changes() > 0
@property
def all_user_names(self):
with self.lock:
return {x for x, in self.conn.cursor().execute(
'SELECT name FROM users')}
def change_password(self, username, pw):
with self.lock:
msg = self.validate_password(pw)
if msg is not None:
raise ValueError(msg)
self.conn.cursor().execute(
'UPDATE users SET pw=? WHERE name=?', (pw, username))