1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2015, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import apsw 7import json 8import os 9import re 10from functools import lru_cache 11from threading import RLock 12 13from calibre import as_unicode 14from calibre.constants import config_dir 15from calibre.utils.config import from_json, to_json 16from polyglot.builtins import iteritems 17 18 19def as_json(data): 20 return json.dumps(data, ensure_ascii=False, default=to_json) 21 22 23def load_json(raw): 24 try: 25 return json.loads(raw, object_hook=from_json) 26 except Exception: 27 return {} 28 29 30@lru_cache(maxsize=1024) 31def parse_restriction(raw): 32 r = load_json(raw) 33 if not isinstance(r, dict): 34 r = {} 35 lr = r.get('library_restrictions', {}) 36 if not isinstance(lr, dict): 37 lr = {} 38 r['allowed_library_names'] = frozenset(map(lambda x: x.lower(), r.get('allowed_library_names', ()))) 39 r['blocked_library_names'] = frozenset(map(lambda x: x.lower(), r.get('blocked_library_names', ()))) 40 r['library_restrictions'] = {k.lower(): v or '' for k, v in iteritems(lr)} 41 return r 42 43 44def serialize_restriction(r): 45 ans = {} 46 for x in 'allowed_library_names blocked_library_names'.split(): 47 v = r.get(x) 48 if v: 49 ans[x] = list(v) 50 ans['library_restrictions'] = {l.lower(): v or '' for l, v in iteritems(r.get('library_restrictions', {}))} 51 return json.dumps(ans) 52 53 54def validate_username(username): 55 if re.sub(r'[-a-zA-Z_0-9 ]', '', username): 56 return _('For maximum compatibility you should use only the letters A-Z,' 57 ' the numbers 0-9, spaces, underscores and hyphens in the username') 58 59 60def validate_password(pw): 61 if not pw: 62 return _('Empty passwords are not allowed') 63 try: 64 pw = pw.encode('ascii', 'strict') 65 except ValueError: 66 return _('The password must contain only ASCII (English) characters and symbols') 67 68 69def create_user_data(pw, readonly=False, restriction=None): 70 return { 71 'pw':pw, 'restriction':parse_restriction(restriction or '{}').copy(), 'readonly': readonly 72 } 73 74 75def connect(path, exc_class=ValueError): 76 try: 77 return apsw.Connection(path) 78 except apsw.CantOpenError as e: 79 pdir = os.path.dirname(path) 80 if os.path.isdir(pdir): 81 raise exc_class('Failed to open userdb database at {} with error: {}'.format(path, as_unicode(e))) 82 try: 83 os.makedirs(pdir) 84 except OSError as e: 85 raise exc_class('Failed to make directory for userdb database at {} with error: {}'.format(pdir, as_unicode(e))) 86 try: 87 return apsw.Connection(path) 88 except apsw.CantOpenError as e: 89 raise exc_class('Failed to open userdb database at {} with error: {}'.format(path, as_unicode(e))) 90 91 92class UserManager: 93 94 lock = RLock() 95 96 @property 97 def conn(self): 98 with self.lock: 99 if self._conn is None: 100 self._conn = connect(self.path) 101 with self._conn: 102 c = self._conn.cursor() 103 uv = next(c.execute('PRAGMA user_version'))[0] 104 if uv == 0: 105 # We have to store the unhashed password, since the digest 106 # auth scheme requires it. (Technically, one can store 107 # a MD5 hash of the username+realm+password, but it has to be 108 # without salt so it is trivially brute-forceable, anyway) 109 # timestamp stores the ISO 8601 creation timestamp in UTC. 110 c.execute(''' 111 CREATE TABLE users ( 112 id INTEGER PRIMARY KEY, 113 name TEXT NOT NULL, 114 pw TEXT NOT NULL, 115 timestamp TEXT DEFAULT CURRENT_TIMESTAMP, 116 session_data TEXT NOT NULL DEFAULT "{}", 117 restriction TEXT NOT NULL DEFAULT "{}", 118 readonly TEXT NOT NULL DEFAULT "n", 119 misc_data TEXT NOT NULL DEFAULT "{}", 120 UNIQUE(name) 121 ); 122 123 PRAGMA user_version=1; 124 ''') 125 c.close() 126 return self._conn 127 128 def __init__(self, path=None): 129 self.path = os.path.join(config_dir, 'server-users.sqlite') if path is None else path 130 self._conn = None 131 132 def get_session_data(self, username): 133 with self.lock: 134 for data, in self.conn.cursor().execute( 135 'SELECT session_data FROM users WHERE name=?', (username,)): 136 return load_json(data) 137 return {} 138 139 def set_session_data(self, username, data): 140 with self.lock: 141 conn = self.conn 142 c = conn.cursor() 143 data = as_json(data) 144 if isinstance(data, bytes): 145 data = data.decode('utf-8') 146 c.execute('UPDATE users SET session_data=? WHERE name=?', (data, username)) 147 148 def get(self, username): 149 ' Get password for user, or None if user does not exist ' 150 with self.lock: 151 for pw, in self.conn.cursor().execute( 152 'SELECT pw FROM users WHERE name=?', (username,)): 153 return pw 154 155 def has_user(self, username): 156 return self.get(username) is not None 157 158 def validate_username(self, username): 159 if self.has_user(username): 160 return _('The username %s already exists') % username 161 return validate_username(username) 162 163 def validate_password(self, pw): 164 return validate_password(pw) 165 166 def add_user(self, username, pw, restriction=None, readonly=False): 167 with self.lock: 168 msg = self.validate_username(username) or self.validate_password(pw) 169 if msg is not None: 170 raise ValueError(msg) 171 restriction = restriction or {} 172 self.conn.cursor().execute( 173 'INSERT INTO users (name, pw, restriction, readonly) VALUES (?, ?, ?, ?)', 174 (username, pw, serialize_restriction(restriction), ('y' if readonly else 'n'))) 175 176 def remove_user(self, username): 177 with self.lock: 178 self.conn.cursor().execute('DELETE FROM users WHERE name=?', (username,)) 179 return self.conn.changes() > 0 180 181 @property 182 def all_user_names(self): 183 with self.lock: 184 return {x for x, in self.conn.cursor().execute( 185 'SELECT name FROM users')} 186 187 @property 188 def user_data(self): 189 with self.lock: 190 ans = {} 191 for name, pw, restriction, readonly in self.conn.cursor().execute('SELECT name,pw,restriction,readonly FROM users'): 192 ans[name] = create_user_data(pw, readonly.lower() == 'y', restriction) 193 return ans 194 195 @user_data.setter 196 def user_data(self, users): 197 with self.lock, self.conn: 198 c = self.conn.cursor() 199 remove = self.all_user_names - set(users) 200 if remove: 201 c.executemany('DELETE FROM users WHERE name=?', [(n,) for n in remove]) 202 for name, data in iteritems(users): 203 res = serialize_restriction(data['restriction']) 204 r = 'y' if data['readonly'] else 'n' 205 c.execute('UPDATE users SET pw=?, restriction=?, readonly=? WHERE name=?', 206 (data['pw'], res, r, name)) 207 if self.conn.changes() > 0: 208 continue 209 c.execute('INSERT INTO USERS (name, pw, restriction, readonly) VALUES (?, ?, ?, ?)', 210 (name, data['pw'], res, r)) 211 self.refresh() 212 213 def refresh(self): 214 pass # legacy compat 215 216 def is_readonly(self, username): 217 with self.lock: 218 for readonly, in self.conn.cursor().execute( 219 'SELECT readonly FROM users WHERE name=?', (username,)): 220 return readonly == 'y' 221 return False 222 223 def set_readonly(self, username, value): 224 with self.lock: 225 self.conn.cursor().execute( 226 'UPDATE users SET readonly=? WHERE name=?', ('y' if value else 'n', username)) 227 228 def change_password(self, username, pw): 229 with self.lock: 230 msg = self.validate_password(pw) 231 if msg is not None: 232 raise ValueError(msg) 233 self.conn.cursor().execute( 234 'UPDATE users SET pw=? WHERE name=?', (pw, username)) 235 236 def restrictions(self, username): 237 with self.lock: 238 for restriction, in self.conn.cursor().execute( 239 'SELECT restriction FROM users WHERE name=?', (username,)): 240 return parse_restriction(restriction).copy() 241 242 def allowed_library_names(self, username, all_library_names): 243 ' Get allowed library names for specified user from set of all library names ' 244 r = self.restrictions(username) 245 if r is None: 246 return set() 247 inc = r['allowed_library_names'] 248 exc = r['blocked_library_names'] 249 250 def check(n): 251 n = n.lower() 252 return (not inc or n in inc) and n not in exc 253 return {n for n in all_library_names if check(n)} 254 255 def update_user_restrictions(self, username, restrictions): 256 if not isinstance(restrictions, dict): 257 raise TypeError('restrictions must be a dict') 258 with self.lock: 259 self.conn.cursor().execute( 260 'UPDATE users SET restriction=? WHERE name=?', (serialize_restriction(restrictions), username)) 261 262 def library_restriction(self, username, library_path): 263 r = self.restrictions(username) 264 if r is None: 265 return '' 266 library_name = os.path.basename(library_path).lower() 267 return r['library_restrictions'].get(library_name) or '' 268