underscorepy 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _/__init__.py +46 -0
- _/application.py +278 -0
- _/auth.py +163 -0
- _/caches/__init__.py +50 -0
- _/caches/dbcache.py +140 -0
- _/caches/memory.py +77 -0
- _/caches/redis.py +104 -0
- _/components.py +107 -0
- _/databases/__init__.py +282 -0
- _/databases/postgres.py +38 -0
- _/databases/sqlite.py +78 -0
- _/handlers.py +45 -0
- _/logins/__init__.py +82 -0
- _/logins/dblogin.py +230 -0
- _/logins/facebook.py +40 -0
- _/logins/gitlab.py +58 -0
- _/logins/google.py +20 -0
- _/logins/htpasswd.py +47 -0
- _/logins/oauth2.py +52 -0
- _/logins/slap.py +56 -0
- _/records/Protobuf.proto +21 -0
- _/records/__init__.py +186 -0
- _/records/data.py +232 -0
- _/records/protobuf.py +496 -0
- _/settings.py +159 -0
- _/supports/__init__.py +28 -0
- _/supports/nginx/__init__.py +78 -0
- _/supports/nginx/nginx.conf +59 -0
- _/supports/systemd/__init__.py +60 -0
- _/supports/systemd/systemd.service +12 -0
- _/test.py +20 -0
- _/utils.py +85 -0
- _/version.py +5 -0
- _/websockets.py +57 -0
- underscorepy-0.9.0.dist-info/METADATA +85 -0
- underscorepy-0.9.0.dist-info/RECORD +39 -0
- underscorepy-0.9.0.dist-info/WHEEL +5 -0
- underscorepy-0.9.0.dist-info/licenses/LICENSE +22 -0
- underscorepy-0.9.0.dist-info/top_level.txt +1 -0
_/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2024 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from .utils import *
|
|
13
|
+
|
|
14
|
+
from . import version
|
|
15
|
+
from . import settings
|
|
16
|
+
from . import auth
|
|
17
|
+
from . import handlers
|
|
18
|
+
from . import components
|
|
19
|
+
|
|
20
|
+
from .application import Application,WebApplication
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class error(Exception):
|
|
24
|
+
'''generic error class for throwing exceptions'''
|
|
25
|
+
|
|
26
|
+
def __init__(self, fmt, *args):
|
|
27
|
+
self.message = fmt % args
|
|
28
|
+
|
|
29
|
+
def __str__(self):
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Container(collections.UserDict):
|
|
34
|
+
'''helper class to hold the loadable components'''
|
|
35
|
+
def __getattr__(self, name):
|
|
36
|
+
try:
|
|
37
|
+
return self[name]
|
|
38
|
+
except KeyError:
|
|
39
|
+
raise AttributeError(name) from None
|
|
40
|
+
|
|
41
|
+
# placeholder for comonents
|
|
42
|
+
caches = Container()
|
|
43
|
+
databases = Container()
|
|
44
|
+
logins = Container()
|
|
45
|
+
records = Container()
|
|
46
|
+
supports = Container()
|
_/application.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import signal
|
|
12
|
+
import traceback
|
|
13
|
+
|
|
14
|
+
import tornado.web
|
|
15
|
+
|
|
16
|
+
import _
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Application:
|
|
20
|
+
@classmethod
|
|
21
|
+
def main(cls, ns=None):
|
|
22
|
+
_.application = self = cls()
|
|
23
|
+
try:
|
|
24
|
+
asyncio.run(self._async_main(ns))
|
|
25
|
+
except _.error as e:
|
|
26
|
+
if _.args.debug:
|
|
27
|
+
traceback.print_tb(e.__traceback__)
|
|
28
|
+
logging.error('%s', e)
|
|
29
|
+
|
|
30
|
+
async def _async_main(self, ns):
|
|
31
|
+
self.loop = asyncio.get_running_loop()
|
|
32
|
+
self._stop_event = asyncio.Event()
|
|
33
|
+
|
|
34
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
35
|
+
try:
|
|
36
|
+
self.loop.add_signal_handler(signum, self.__signalHandler, signum, None)
|
|
37
|
+
except NotImplementedError:
|
|
38
|
+
# Windows event loops can't add signal handlers
|
|
39
|
+
signal.signal(signum, self.__signalHandler)
|
|
40
|
+
|
|
41
|
+
name = self.__class__.__name__.lower()
|
|
42
|
+
try:
|
|
43
|
+
await _.settings.load(ns=ns, name=name)
|
|
44
|
+
await self.logging()
|
|
45
|
+
except _.error as e:
|
|
46
|
+
if _.args.debug:
|
|
47
|
+
traceback.print_tb(e.__traceback__)
|
|
48
|
+
logging.error('%s', e)
|
|
49
|
+
self.stop()
|
|
50
|
+
|
|
51
|
+
if not self._stop_event.is_set():
|
|
52
|
+
try:
|
|
53
|
+
await self._async_init()
|
|
54
|
+
except _.error as e:
|
|
55
|
+
logging.error('%s', e)
|
|
56
|
+
|
|
57
|
+
for name,component in _.caches.items():
|
|
58
|
+
await component.close()
|
|
59
|
+
|
|
60
|
+
for name,component in _.databases.items():
|
|
61
|
+
await component.close()
|
|
62
|
+
|
|
63
|
+
async def _async_init(self, **kwds):
|
|
64
|
+
# call the underscore application's entry point
|
|
65
|
+
try:
|
|
66
|
+
await _.wait(self.initialize())
|
|
67
|
+
except NotImplementedError:
|
|
68
|
+
logging.warning('No "initialize" function defined')
|
|
69
|
+
|
|
70
|
+
# block until the stop event is set
|
|
71
|
+
await self._stop_event.wait()
|
|
72
|
+
# call clean-up code
|
|
73
|
+
await _.wait(self.on_stop())
|
|
74
|
+
|
|
75
|
+
async def initialize(self):
|
|
76
|
+
'''underscore app entry point'''
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
async def logging(self):
|
|
80
|
+
'''underscore apps can override or extend this function'''
|
|
81
|
+
|
|
82
|
+
# add the handlers to the logger
|
|
83
|
+
if _.config.getboolean(_.name, 'logging', fallback=False):
|
|
84
|
+
full_path = _.paths(f'{_.name}.log')
|
|
85
|
+
file_logger = logging.FileHandler(full_path)
|
|
86
|
+
file_logger.setLevel(logging.DEBUG if _.args.debug else logging.INFO)
|
|
87
|
+
file_logger.setFormatter(
|
|
88
|
+
logging.Formatter(
|
|
89
|
+
fmt = '%(asctime)s %(levelname)-8s %(message)s',
|
|
90
|
+
datefmt = '%Y-%m-%d %H:%M:%S',
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
root_logger = logging.getLogger()
|
|
94
|
+
root_logger.addHandler(file_logger)
|
|
95
|
+
|
|
96
|
+
async def sleep(self, _timeout):
|
|
97
|
+
try:
|
|
98
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=_timeout)
|
|
99
|
+
except asyncio.TimeoutError:
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def task(self, fn, *args, **kwds):
|
|
104
|
+
async def _task():
|
|
105
|
+
await _.wait(fn(*args, **kwds))
|
|
106
|
+
return asyncio.create_task(_task())
|
|
107
|
+
|
|
108
|
+
def periodic(self, _timeout, fn, *args, **kwds):
|
|
109
|
+
'''run a function or coroutine on a recurring basis'''
|
|
110
|
+
async def _periodic():
|
|
111
|
+
while True:
|
|
112
|
+
# bail if the stop event is set
|
|
113
|
+
# otherwise run the function after the timeout occurs
|
|
114
|
+
try:
|
|
115
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=_timeout)
|
|
116
|
+
break
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
pass
|
|
119
|
+
try:
|
|
120
|
+
await _.wait(fn(*args, **kwds))
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logging.exception(e)
|
|
123
|
+
return asyncio.create_task(_periodic())
|
|
124
|
+
|
|
125
|
+
def stop(self):
|
|
126
|
+
logging.debug('Setting stop event')
|
|
127
|
+
self._stop_event.set()
|
|
128
|
+
|
|
129
|
+
def on_stop(self):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def _record_handler(self, component, cls):
|
|
133
|
+
'''records and caches register REST handlers here, only a WebApplication serves them'''
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def _login_handler(self, component, cls):
|
|
137
|
+
'''logins register their handlers here, only a WebApplication serves them'''
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def __signalHandler(self, signum, frame):
|
|
141
|
+
'''handle signals in a thread-safe way, frame is None unless on Windows'''
|
|
142
|
+
signame = signal.Signals(signum).name
|
|
143
|
+
handler = getattr(self, f'on_{signame.lower()}', None)
|
|
144
|
+
if handler:
|
|
145
|
+
if handler(signum, frame):
|
|
146
|
+
return
|
|
147
|
+
logging.info('Terminating %s on %s signal', _.name, signame)
|
|
148
|
+
self.loop.call_soon_threadsafe(self.stop)
|
|
149
|
+
|
|
150
|
+
# demonstrate a specific signal handler
|
|
151
|
+
# and print newline after ^C on terminals
|
|
152
|
+
def on_sigint(self, signum, frame):
|
|
153
|
+
print()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class WebApplication(Application, tornado.web.Application):
|
|
157
|
+
async def _async_main(self, ns):
|
|
158
|
+
self._records_patterns = []
|
|
159
|
+
self._login_patterns = []
|
|
160
|
+
self.patterns = []
|
|
161
|
+
self.settings = {}
|
|
162
|
+
|
|
163
|
+
_.argparser.add_argument('--address', '-a',
|
|
164
|
+
metavar='<address>',
|
|
165
|
+
help = 'Interface to bind to')
|
|
166
|
+
|
|
167
|
+
_.argparser.add_argument('--port', '-p',
|
|
168
|
+
metavar='<port>',
|
|
169
|
+
type=int,
|
|
170
|
+
help='Port to listen on')
|
|
171
|
+
|
|
172
|
+
await super(WebApplication, self)._async_main(ns)
|
|
173
|
+
|
|
174
|
+
async def _async_init(self, **kwds):
|
|
175
|
+
_.sessions = _.config.get(_.name, 'sessions', fallback=None)
|
|
176
|
+
if _.sessions:
|
|
177
|
+
logging.debug('Sessions cache: %s', _.sessions)
|
|
178
|
+
try:
|
|
179
|
+
_.sessions = _.caches[_.sessions]
|
|
180
|
+
except KeyError:
|
|
181
|
+
raise _.error('Unknown sessions cache instance: %s', _.sessions)
|
|
182
|
+
|
|
183
|
+
self.settings['static_path'] = _.paths('static')
|
|
184
|
+
self.settings['template_path'] = _.paths('templates')
|
|
185
|
+
self.settings['debug'] = _.args.debug
|
|
186
|
+
if 'cookie_secret' not in self.settings:
|
|
187
|
+
self.settings['cookie_secret'] = await self.cookie_secret()
|
|
188
|
+
|
|
189
|
+
# call the underscore application's entry point
|
|
190
|
+
try:
|
|
191
|
+
await _.wait(self.initialize())
|
|
192
|
+
except NotImplementedError:
|
|
193
|
+
logging.warning('No "initialize" function defined')
|
|
194
|
+
|
|
195
|
+
patterns = list(self._records_patterns)
|
|
196
|
+
|
|
197
|
+
if self._login_patterns:
|
|
198
|
+
patterns.extend(self._login_patterns)
|
|
199
|
+
patterns.extend([
|
|
200
|
+
( r'/login', _.logins.LoginPage ),
|
|
201
|
+
( r'/logout', _.logins.Logout ),
|
|
202
|
+
])
|
|
203
|
+
self.settings['login_url'] = '/login'
|
|
204
|
+
|
|
205
|
+
patterns.extend(self.patterns)
|
|
206
|
+
|
|
207
|
+
# prepend prefix specified in the ini file, useful for nginx reverse proxy
|
|
208
|
+
prefix = _.config.get(_.name, 'prefix', fallback='')
|
|
209
|
+
if prefix:
|
|
210
|
+
if not prefix[0] == '/':
|
|
211
|
+
prefix = '/' + prefix
|
|
212
|
+
patterns = [(f'{prefix}{r[0]}',) + tuple(r[1:]) for r in patterns]
|
|
213
|
+
else:
|
|
214
|
+
patterns.append(
|
|
215
|
+
( r'/(favicon.ico)', tornado.web.StaticFileHandler, {'path':''}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if _.args.debug:
|
|
219
|
+
for (pattern,cls,*params) in patterns:
|
|
220
|
+
handler = f'{cls.__module__}.{cls.__name__}'
|
|
221
|
+
logging.debug('%-32s %s %s', pattern, handler, params[0] if params else '')
|
|
222
|
+
|
|
223
|
+
await self.__listen(patterns)
|
|
224
|
+
|
|
225
|
+
# block until the stop event is set
|
|
226
|
+
await self._stop_event.wait()
|
|
227
|
+
# call clean-up code
|
|
228
|
+
await _.wait(self.on_stop())
|
|
229
|
+
|
|
230
|
+
async def __listen(self, patterns, **kwds):
|
|
231
|
+
'''call the Tornado Application init here to give children a chance
|
|
232
|
+
to initialize patterns and settings'''
|
|
233
|
+
super().__init__(patterns, **self.settings)
|
|
234
|
+
|
|
235
|
+
if not _.args.address:
|
|
236
|
+
_.args.address = _.config.get(_.name, 'address', fallback='127.0.0.1')
|
|
237
|
+
|
|
238
|
+
if not _.args.port:
|
|
239
|
+
_.args.port = _.config.getint(_.name, 'port', fallback=8080)
|
|
240
|
+
|
|
241
|
+
if 'xheaders' not in kwds:
|
|
242
|
+
kwds['xheaders'] = True
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
self.listen(_.args.port, _.args.address, **kwds)
|
|
246
|
+
logging.info('Listening on %s:%d', _.args.address, _.args.port)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
raise _.error('%s', e) from None
|
|
249
|
+
|
|
250
|
+
def _record_handler(self, component, cls):
|
|
251
|
+
'''called from _.records component to add handler'''
|
|
252
|
+
self._records_patterns.append(
|
|
253
|
+
(f'/{component}/{cls._component}(?:/(.*))?', cls)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _login_handler(self, component, cls):
|
|
257
|
+
'''called from _.logins component to add handler'''
|
|
258
|
+
self._login_patterns.append(
|
|
259
|
+
(f'/{component}/{cls._component}', cls)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def cookie_secret(self):
|
|
263
|
+
'''underscore apps can override this function'''
|
|
264
|
+
if _.sessions is not None:
|
|
265
|
+
return await _.wait(_.sessions.cookie_secret())
|
|
266
|
+
|
|
267
|
+
async def on_login_success(self, handler, user, *args, **kwds):
|
|
268
|
+
'''underscore apps should override this function if a login component is specified'''
|
|
269
|
+
raise NotImplementedError
|
|
270
|
+
|
|
271
|
+
async def on_login_failure(self, component, *args, **kwds):
|
|
272
|
+
'''underscore apps should override this function if a login component is specified'''
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
async def on_logout(self, sessions_id):
|
|
276
|
+
'''underscore apps should override this function if a login component is specified'''
|
|
277
|
+
pass
|
|
278
|
+
|
_/auth.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
import functools
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import os
|
|
15
|
+
import urllib.parse
|
|
16
|
+
|
|
17
|
+
import _
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# based on tornado.web.authenticated
|
|
21
|
+
def _next(handler):
|
|
22
|
+
if handler.request.method in ("GET","HEAD"):
|
|
23
|
+
url = handler.get_login_url()
|
|
24
|
+
if "?" not in url:
|
|
25
|
+
if urllib.parse.urlsplit(url).scheme:
|
|
26
|
+
next_url = handler.request.full_url()
|
|
27
|
+
else:
|
|
28
|
+
next_url = handler.request.uri
|
|
29
|
+
url += "?" + urllib.parse.urlencode(dict(next=next_url))
|
|
30
|
+
handler.redirect(url)
|
|
31
|
+
else:
|
|
32
|
+
raise _.HTTPError(403)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# generic decorator to callback to a function
|
|
36
|
+
# return true to allow the method to be called
|
|
37
|
+
# return false to block the method
|
|
38
|
+
def filter(filter_func):
|
|
39
|
+
def _filter(method):
|
|
40
|
+
@functools.wraps(method)
|
|
41
|
+
def wrapper(self, *args, **kwds):
|
|
42
|
+
if filter_func(self):
|
|
43
|
+
return method(self, *args, **kwds)
|
|
44
|
+
_next(self)
|
|
45
|
+
return wrapper
|
|
46
|
+
return _filter
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# same as above but pass the current_user session to the function
|
|
50
|
+
# blocks when no session is associated with the request
|
|
51
|
+
# return true to allow the method to be called
|
|
52
|
+
# return false to block the method
|
|
53
|
+
def filter_user(filter_func):
|
|
54
|
+
def _filter_user(method):
|
|
55
|
+
@functools.wraps(method)
|
|
56
|
+
def wrapper(self, *args, **kwds):
|
|
57
|
+
if self.current_user:
|
|
58
|
+
if filter_func(self.current_user):
|
|
59
|
+
return method(self, *args, **kwds)
|
|
60
|
+
_next(self)
|
|
61
|
+
return wrapper
|
|
62
|
+
return _filter_user
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# inteded to be used by underscore apps to check that any user is logged in
|
|
66
|
+
current_user = filter_user(lambda current_user: True)
|
|
67
|
+
|
|
68
|
+
# intended to be over-ridden before components are loaded for more fine-grain
|
|
69
|
+
# control over access to component handlers
|
|
70
|
+
protected = filter_user(lambda current_user: True)
|
|
71
|
+
|
|
72
|
+
# default record access is unprotected, override before components are loaded
|
|
73
|
+
# to require a session for the _.records handlers, for example:
|
|
74
|
+
#
|
|
75
|
+
# _.auth.records = _.auth.filter_user(lambda session: session)
|
|
76
|
+
#
|
|
77
|
+
records = lambda fn: fn
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# implement old school user/name password prompt
|
|
81
|
+
# useful for command-line tools to pass as part of the URL
|
|
82
|
+
def basic(realm='Authentication'):
|
|
83
|
+
def basic_auth(method):
|
|
84
|
+
@functools.wraps(method)
|
|
85
|
+
async def wrapper(self, *args, **kwds):
|
|
86
|
+
auth = self.request.headers.get('Authorization', '')
|
|
87
|
+
if auth.startswith('Basic '):
|
|
88
|
+
try:
|
|
89
|
+
auth = binascii.a2b_base64(auth[6:]).decode('utf-8')
|
|
90
|
+
username,password = auth.split(':', 1)
|
|
91
|
+
except (binascii.Error,UnicodeDecodeError,ValueError):
|
|
92
|
+
raise _.HTTPError(400) from None
|
|
93
|
+
component = _.config.get(_.name, 'basic', fallback=None)
|
|
94
|
+
if not component:
|
|
95
|
+
raise _.HTTPError(500, 'No component specified for basic auth')
|
|
96
|
+
try:
|
|
97
|
+
login = _.logins[component]
|
|
98
|
+
except KeyError:
|
|
99
|
+
raise _.HTTPError(500, 'Invalid component specified for basic auth')
|
|
100
|
+
success = await login.check(username, password)
|
|
101
|
+
if success:
|
|
102
|
+
return await _.wait(method(self, *args, **kwds))
|
|
103
|
+
self.set_status(401)
|
|
104
|
+
self.set_header('WWW-Authenticate', f'Basic realm="{realm}"')
|
|
105
|
+
self.finish()
|
|
106
|
+
return wrapper
|
|
107
|
+
return basic_auth
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# scrypt parameters for new hashes, stored alongside each hash so they can be
|
|
111
|
+
# raised later without invalidating existing passwords
|
|
112
|
+
SCRYPT_N = 2 ** 15
|
|
113
|
+
SCRYPT_R = 8
|
|
114
|
+
SCRYPT_P = 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _scrypt(password, salt, n, r, p):
|
|
118
|
+
return hashlib.scrypt(password.encode('utf-8'), salt=salt,
|
|
119
|
+
n=n, r=r, p=p, maxmem=128 * r * (n + p + 2), dklen=64)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hash_password(password):
|
|
123
|
+
'''return a salted scrypt hash formatted as scrypt$n$r$p$salt$hash'''
|
|
124
|
+
salt = os.urandom(16)
|
|
125
|
+
digest = _scrypt(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P)
|
|
126
|
+
salt = base64.b64encode(salt).decode('ascii')
|
|
127
|
+
digest = base64.b64encode(digest).decode('ascii')
|
|
128
|
+
return f'scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${salt}${digest}'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify_password(password, stored, username=''):
|
|
132
|
+
'''check a password against a stored hash, username is only used by legacy hashes'''
|
|
133
|
+
if not password or not stored:
|
|
134
|
+
return False
|
|
135
|
+
if not stored.startswith('scrypt$'):
|
|
136
|
+
return hmac.compare_digest(simple_hash(username + password), stored)
|
|
137
|
+
try:
|
|
138
|
+
_scheme,n,r,p,salt,digest = stored.split('$')
|
|
139
|
+
n,r,p = int(n), int(r), int(p)
|
|
140
|
+
salt = base64.b64decode(salt)
|
|
141
|
+
digest = base64.b64decode(digest)
|
|
142
|
+
except (ValueError,binascii.Error):
|
|
143
|
+
return False
|
|
144
|
+
return hmac.compare_digest(_scrypt(password, salt, n, r, p), digest)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def needs_rehash(stored):
|
|
148
|
+
'''true for legacy hashes or hashes made with weaker parameters'''
|
|
149
|
+
if not stored or not stored.startswith('scrypt$'):
|
|
150
|
+
return True
|
|
151
|
+
try:
|
|
152
|
+
n,r,p = (int(v) for v in stored.split('$')[1:4])
|
|
153
|
+
except ValueError:
|
|
154
|
+
return True
|
|
155
|
+
return (n,r,p) != (SCRYPT_N,SCRYPT_R,SCRYPT_P)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def simple_hash(value):
|
|
159
|
+
'''legacy unsalted hash, only kept to verify existing passwords'''
|
|
160
|
+
value = value.encode('utf-8')
|
|
161
|
+
for i in range(99999):
|
|
162
|
+
value = hashlib.sha512(value).digest()
|
|
163
|
+
return base64.b64encode(value).decode('ascii')
|
_/caches/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import _
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Cache:
|
|
13
|
+
@classmethod
|
|
14
|
+
async def _(cls, component_name, **kwds):
|
|
15
|
+
# create a dynamic child class with kwds from the ini file
|
|
16
|
+
try:
|
|
17
|
+
members = dict(_.config['sessions'])
|
|
18
|
+
except KeyError:
|
|
19
|
+
members = {}
|
|
20
|
+
|
|
21
|
+
members['expires'] = int(members.get('expires', 24))
|
|
22
|
+
members['interval'] = int(members.get('interval', 60))
|
|
23
|
+
members.update(kwds)
|
|
24
|
+
|
|
25
|
+
# instantiate the derived class
|
|
26
|
+
self = type(cls.__name__, (cls,), _.prefix(members))()
|
|
27
|
+
_.caches[component_name] = self
|
|
28
|
+
await self.init(component_name, **kwds)
|
|
29
|
+
|
|
30
|
+
async def init(self, component_name, **kwds):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
async def close(self):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
async def cookie_secret(self):
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def save_session(self, session):
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def load_session(self, session_id):
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def clear_session(self, session_id):
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Handler(_.handlers.Protected):
|
|
50
|
+
pass
|
_/caches/dbcache.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import typing
|
|
14
|
+
|
|
15
|
+
import sqlalchemy
|
|
16
|
+
|
|
17
|
+
import _
|
|
18
|
+
import _.records
|
|
19
|
+
|
|
20
|
+
class DbCache(_.caches.Cache):
|
|
21
|
+
_config = 'config'
|
|
22
|
+
_key_col = 'key'
|
|
23
|
+
_val_col = 'value'
|
|
24
|
+
_key = 'cookie'
|
|
25
|
+
|
|
26
|
+
_table = 'sessions'
|
|
27
|
+
_session_col = 'session_id'
|
|
28
|
+
|
|
29
|
+
async def init(self, component_name, database=None, **kwds):
|
|
30
|
+
if not hasattr(_.application, 'is_session_expired'):
|
|
31
|
+
raise _.error('Application does not have is_session_expired function defined')
|
|
32
|
+
|
|
33
|
+
if database is None:
|
|
34
|
+
if 1 == len(_.databases):
|
|
35
|
+
database = list(_.databases.keys())[0]
|
|
36
|
+
else:
|
|
37
|
+
raise _.error('dbcache requires a database to be specified')
|
|
38
|
+
self.db = _.databases[database]
|
|
39
|
+
|
|
40
|
+
self.config_table = type(self._config, (_.records.RecordsInterface,self.db.Base,), {
|
|
41
|
+
'__tablename__' : self._config,
|
|
42
|
+
'__annotations__' : {
|
|
43
|
+
self._key_col : str,
|
|
44
|
+
self._val_col : typing.Optional[str],
|
|
45
|
+
},
|
|
46
|
+
'__primary_key__' : self._key_col,
|
|
47
|
+
self._key_col : sqlalchemy.orm.mapped_column(primary_key=True, init=False),
|
|
48
|
+
self._val_col : sqlalchemy.orm.mapped_column(init=False),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
annotations = {
|
|
52
|
+
self._session_col : str,
|
|
53
|
+
}
|
|
54
|
+
# create the session table
|
|
55
|
+
columns = {
|
|
56
|
+
'__tablename__' : self._table,
|
|
57
|
+
'__annotations__' : annotations,
|
|
58
|
+
'__primary_key__' : self._session_col,
|
|
59
|
+
self._session_col : sqlalchemy.orm.mapped_column(primary_key=True, init=False),
|
|
60
|
+
}
|
|
61
|
+
for col,dbtype in kwds.items():
|
|
62
|
+
if not dbtype:
|
|
63
|
+
dbtype = 'str'
|
|
64
|
+
annotations[col] = typing.Optional[__builtins__.get(dbtype)]
|
|
65
|
+
columns[col] = sqlalchemy.orm.mapped_column(init=False)
|
|
66
|
+
|
|
67
|
+
self.session_table = type(self._table, (_.records.RecordsInterface, self.db.Base,), columns)
|
|
68
|
+
|
|
69
|
+
# interval comes from the [sessions] section of the ini
|
|
70
|
+
_.application.periodic(self._interval, self.clear_stale_sessions)
|
|
71
|
+
|
|
72
|
+
members = dict(
|
|
73
|
+
component = component_name,
|
|
74
|
+
db = self.db,
|
|
75
|
+
cls = self.session_table,
|
|
76
|
+
table = self._table,
|
|
77
|
+
session_id = self._session_col,
|
|
78
|
+
)
|
|
79
|
+
subclass = type('Sessions', (Sessions,), _.prefix(members))
|
|
80
|
+
_.application._record_handler('sessions', subclass)
|
|
81
|
+
|
|
82
|
+
async def cookie_secret(self):
|
|
83
|
+
# try to load exisiting cookie secret and return it
|
|
84
|
+
secret = await self.db.find_one(self.config_table, self._key)
|
|
85
|
+
if secret:
|
|
86
|
+
secret = secret[self._val_col]
|
|
87
|
+
else:
|
|
88
|
+
# otherwise generate and store the cookie secret
|
|
89
|
+
secret = base64.b64encode(os.urandom(32)).decode('ascii')
|
|
90
|
+
config = self.config_table()
|
|
91
|
+
config(**{self._key_col : self._key, self._val_col : secret})
|
|
92
|
+
await self.db.upsert(config)
|
|
93
|
+
return secret
|
|
94
|
+
|
|
95
|
+
async def save_session(self, session):
|
|
96
|
+
session = self.session_table._from_dict(**session)
|
|
97
|
+
await self.db.upsert(session)
|
|
98
|
+
|
|
99
|
+
async def load_session(self, session_id):
|
|
100
|
+
session = await self.db.find_one(self.session_table, session_id)
|
|
101
|
+
if not session:
|
|
102
|
+
return None
|
|
103
|
+
if await _.wait(_.application.is_session_expired(session, self._expires)):
|
|
104
|
+
return None
|
|
105
|
+
return session
|
|
106
|
+
|
|
107
|
+
async def clear_session(self, session_id):
|
|
108
|
+
session = await self.db.find_one(self.session_table, session_id)
|
|
109
|
+
if session:
|
|
110
|
+
await self.db.delete(session)
|
|
111
|
+
|
|
112
|
+
async def clear_stale_sessions(self):
|
|
113
|
+
for record in await self.db.find(self.session_table):
|
|
114
|
+
if await _.wait(_.application.is_session_expired(record, self._expires)):
|
|
115
|
+
logging.debug('Removing expired session: %s', record[self._session_col])
|
|
116
|
+
await self.db.delete(record)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Sessions(_.handlers.Protected):
|
|
120
|
+
@_.auth.protected
|
|
121
|
+
async def get(self, session_id=None):
|
|
122
|
+
if session_id:
|
|
123
|
+
record = await self._db.find_one(self._cls, session_id)
|
|
124
|
+
self.write(record._as_dict())
|
|
125
|
+
else:
|
|
126
|
+
records = await self._db.find(self._cls)
|
|
127
|
+
data = [r._as_dict() for r in records]
|
|
128
|
+
self.write({'data':data})
|
|
129
|
+
|
|
130
|
+
@_.auth.protected
|
|
131
|
+
async def delete(self, session_id=None):
|
|
132
|
+
if session_id:
|
|
133
|
+
await self._db.delete(self._cls, session_id)
|
|
134
|
+
|
|
135
|
+
callback = getattr(_.application, f'on_{self._component}_delete', None)
|
|
136
|
+
if callback is None:
|
|
137
|
+
callback = getattr(_.application, 'on_dbcache_delete', None)
|
|
138
|
+
if callback:
|
|
139
|
+
await _.wait(callback(self._component, session_id))
|
|
140
|
+
self.set_status(204)
|