underscorepy 0.6.3__tar.gz → 0.9.0__tar.gz

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.
Files changed (72) hide show
  1. underscorepy-0.9.0/PKG-INFO +85 -0
  2. underscorepy-0.9.0/README.md +48 -0
  3. underscorepy-0.9.0/_/__init__.py +46 -0
  4. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/application.py +159 -102
  5. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/auth.py +65 -7
  6. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/caches/__init__.py +9 -9
  7. underscorepy-0.9.0/_/caches/dbcache.py +140 -0
  8. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/caches/memory.py +22 -16
  9. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/caches/redis.py +18 -9
  10. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/components.py +21 -14
  11. underscorepy-0.9.0/_/databases/__init__.py +282 -0
  12. underscorepy-0.9.0/_/databases/postgres.py +38 -0
  13. underscorepy-0.9.0/_/databases/sqlite.py +78 -0
  14. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/handlers.py +6 -1
  15. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/__init__.py +25 -15
  16. underscorepy-0.9.0/_/logins/dblogin.py +230 -0
  17. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/facebook.py +1 -1
  18. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/gitlab.py +13 -4
  19. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/google.py +2 -2
  20. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/htpasswd.py +1 -9
  21. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/oauth2.py +3 -3
  22. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/logins/slap.py +2 -6
  23. underscorepy-0.9.0/_/records/Protobuf.proto +21 -0
  24. underscorepy-0.9.0/_/records/__init__.py +186 -0
  25. underscorepy-0.9.0/_/records/data.py +232 -0
  26. underscorepy-0.9.0/_/records/protobuf.py +496 -0
  27. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/settings.py +46 -36
  28. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/supports/__init__.py +5 -6
  29. underscorepy-0.9.0/_/supports/nginx/__init__.py +78 -0
  30. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/supports/nginx/nginx.conf +5 -5
  31. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/supports/systemd/__init__.py +13 -7
  32. underscorepy-0.9.0/_/test.py +20 -0
  33. underscorepy-0.9.0/_/utils.py +85 -0
  34. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/version.py +1 -1
  35. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/websockets.py +11 -3
  36. underscorepy-0.9.0/pyproject.toml +49 -0
  37. underscorepy-0.9.0/setup.py +6 -0
  38. underscorepy-0.9.0/tests/test_application.py +80 -0
  39. underscorepy-0.9.0/tests/test_auth.py +124 -0
  40. underscorepy-0.9.0/tests/test_components.py +117 -0
  41. underscorepy-0.9.0/tests/test_migrations.py +188 -0
  42. underscorepy-0.9.0/tests/test_protobuf.py +408 -0
  43. underscorepy-0.9.0/tests/test_records.py +169 -0
  44. underscorepy-0.9.0/tests/test_settings.py +40 -0
  45. underscorepy-0.9.0/underscorepy.egg-info/PKG-INFO +85 -0
  46. {underscorepy-0.6.3 → underscorepy-0.9.0}/underscorepy.egg-info/SOURCES.txt +9 -2
  47. underscorepy-0.9.0/underscorepy.egg-info/requires.txt +28 -0
  48. underscorepy-0.6.3/MANIFEST.in +0 -2
  49. underscorepy-0.6.3/PKG-INFO +0 -23
  50. underscorepy-0.6.3/README.md +0 -7
  51. underscorepy-0.6.3/_/__init__.py +0 -32
  52. underscorepy-0.6.3/_/caches/dbcache.py +0 -117
  53. underscorepy-0.6.3/_/databases/__init__.py +0 -178
  54. underscorepy-0.6.3/_/databases/postgres.py +0 -97
  55. underscorepy-0.6.3/_/databases/sqlite.py +0 -151
  56. underscorepy-0.6.3/_/logins/dblogin.py +0 -194
  57. underscorepy-0.6.3/_/records/Protobuf.proto +0 -19
  58. underscorepy-0.6.3/_/records/Protobuf_pb2.py +0 -35
  59. underscorepy-0.6.3/_/records/__init__.py +0 -197
  60. underscorepy-0.6.3/_/records/data.py +0 -186
  61. underscorepy-0.6.3/_/records/protobuf.py +0 -149
  62. underscorepy-0.6.3/_/supports/nginx/__init__.py +0 -58
  63. underscorepy-0.6.3/_/utils.py +0 -99
  64. underscorepy-0.6.3/setup.py +0 -29
  65. underscorepy-0.6.3/underscorepy.egg-info/PKG-INFO +0 -23
  66. underscorepy-0.6.3/underscorepy.egg-info/requires.txt +0 -1
  67. {underscorepy-0.6.3 → underscorepy-0.9.0}/LICENSE +0 -0
  68. {underscorepy-0.6.3 → underscorepy-0.9.0}/_/supports/systemd/systemd.service +0 -0
  69. {underscorepy-0.6.3 → underscorepy-0.9.0}/setup.cfg +0 -0
  70. {underscorepy-0.6.3 → underscorepy-0.9.0}/underscorepy.egg-info/dependency_links.txt +0 -0
  71. {underscorepy-0.6.3 → underscorepy-0.9.0}/underscorepy.egg-info/not-zip-safe +0 -0
  72. {underscorepy-0.6.3 → underscorepy-0.9.0}/underscorepy.egg-info/top_level.txt +0 -0
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: underscorepy
3
+ Version: 0.9.0
4
+ Summary: _.py library
5
+ Author-email: Matthew Shaw <mshaw.cx@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://shaw.cx/_.py
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: tornado>=6.3
17
+ Requires-Dist: sqlalchemy[asyncio]>=2
18
+ Provides-Extra: postgres
19
+ Requires-Dist: asyncpg; extra == "postgres"
20
+ Provides-Extra: sqlite
21
+ Requires-Dist: aiosqlite; extra == "sqlite"
22
+ Provides-Extra: redis
23
+ Requires-Dist: redis>=5.0.1; extra == "redis"
24
+ Provides-Extra: protobuf
25
+ Requires-Dist: protobuf>=5.26; extra == "protobuf"
26
+ Provides-Extra: ldap
27
+ Requires-Dist: python-ldap; extra == "ldap"
28
+ Provides-Extra: migrations
29
+ Requires-Dist: alembic; extra == "migrations"
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest; extra == "test"
32
+ Requires-Dist: aiosqlite; extra == "test"
33
+ Requires-Dist: alembic; extra == "test"
34
+ Provides-Extra: all
35
+ Requires-Dist: underscorepy[ldap,migrations,postgres,protobuf,redis,sqlite]; extra == "all"
36
+ Dynamic: license-file
37
+
38
+
39
+ underscore.py
40
+ -------------
41
+
42
+ ```INI
43
+ [myapp]
44
+ address = 0.0.0.0
45
+ port = 8080
46
+ ```
47
+
48
+ ```Python
49
+ import _
50
+ import _.websockets
51
+
52
+ class Echo(_.websockets.WebSocket):
53
+ def on_message(self, msg):
54
+ print('Echo:', msg)
55
+ self.write_message(msg)
56
+
57
+ class MyApp(_.Application):
58
+ def initialize(self):
59
+ self.patterns = [
60
+ ( r'/ws', Echo ),
61
+ ( r'/([a-z]*)', _.handlers.Template ),
62
+ ]
63
+
64
+ MyApp.main()
65
+ ```
66
+
67
+ Look at the skeleton directory for a more complete example application.
68
+
69
+ Schema changes
70
+ --------------
71
+
72
+ Tables for records are created when the database is empty. After that, each
73
+ start compares the models (for example your `.proto` messages) with the
74
+ database and refuses to run if they differ:
75
+
76
+ ```
77
+ ERROR Database "store" differs from the schema:
78
+ ALTER TABLE "Reports_multiple" ADD COLUMN _position INTEGER
79
+ Run with --store-migrate to apply them
80
+ ```
81
+
82
+ `--<database>-migrate` applies changes that can't lose data. Drops and type
83
+ changes are printed as SQL to run by hand. This needs `alembic`
84
+ (`pip install underscorepy[migrations]`); set `check_schema = false` in the
85
+ database's section to skip the check.
@@ -0,0 +1,48 @@
1
+
2
+ underscore.py
3
+ -------------
4
+
5
+ ```INI
6
+ [myapp]
7
+ address = 0.0.0.0
8
+ port = 8080
9
+ ```
10
+
11
+ ```Python
12
+ import _
13
+ import _.websockets
14
+
15
+ class Echo(_.websockets.WebSocket):
16
+ def on_message(self, msg):
17
+ print('Echo:', msg)
18
+ self.write_message(msg)
19
+
20
+ class MyApp(_.Application):
21
+ def initialize(self):
22
+ self.patterns = [
23
+ ( r'/ws', Echo ),
24
+ ( r'/([a-z]*)', _.handlers.Template ),
25
+ ]
26
+
27
+ MyApp.main()
28
+ ```
29
+
30
+ Look at the skeleton directory for a more complete example application.
31
+
32
+ Schema changes
33
+ --------------
34
+
35
+ Tables for records are created when the database is empty. After that, each
36
+ start compares the models (for example your `.proto` messages) with the
37
+ database and refuses to run if they differ:
38
+
39
+ ```
40
+ ERROR Database "store" differs from the schema:
41
+ ALTER TABLE "Reports_multiple" ADD COLUMN _position INTEGER
42
+ Run with --store-migrate to apply them
43
+ ```
44
+
45
+ `--<database>-migrate` applies changes that can't lose data. Drops and type
46
+ changes are printed as SQL to run by hand. This needs `alembic`
47
+ (`pip install underscorepy[migrations]`); set `check_schema = false` in the
48
+ database's section to skip the check.
@@ -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()
@@ -8,10 +8,7 @@
8
8
 
9
9
  import asyncio
10
10
  import logging
11
- import os
12
11
  import signal
13
- import socket
14
- import sys
15
12
  import traceback
16
13
 
17
14
  import tornado.web
@@ -19,29 +16,27 @@ import tornado.web
19
16
  import _
20
17
 
21
18
 
22
- class Application(tornado.web.Application):
19
+ class Application:
23
20
  @classmethod
24
21
  def main(cls, ns=None):
25
22
  _.application = self = cls()
26
23
  try:
27
- asyncio.run(self.__async_main(ns))
24
+ asyncio.run(self._async_main(ns))
28
25
  except _.error as e:
29
26
  if _.args.debug:
30
27
  traceback.print_tb(e.__traceback__)
31
28
  logging.error('%s', e)
32
29
 
33
- async def __async_main(self, ns):
34
- self._records_patterns = []
35
- self._login_patterns = []
36
-
37
- self.patterns = []
38
- self.settings = {}
39
-
40
- self.loop = asyncio.get_event_loop()
30
+ async def _async_main(self, ns):
31
+ self.loop = asyncio.get_running_loop()
41
32
  self._stop_event = asyncio.Event()
42
33
 
43
- signal.signal(signal.SIGINT, self.__signalHandler)
44
- signal.signal(signal.SIGTERM, self.__signalHandler)
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)
45
40
 
46
41
  name = self.__class__.__name__.lower()
47
42
  try:
@@ -53,18 +48,9 @@ class Application(tornado.web.Application):
53
48
  logging.error('%s', e)
54
49
  self.stop()
55
50
 
56
- if not self._stop_event.is_set():
57
- for name,component in _.logins.items():
58
- try:
59
- await _.wait(component.args(name))
60
- except _.error as e:
61
- logging.error('%s', e)
62
- self.stop()
63
- break
64
-
65
51
  if not self._stop_event.is_set():
66
52
  try:
67
- await self.__async_init()
53
+ await self._async_init()
68
54
  except _.error as e:
69
55
  logging.error('%s', e)
70
56
 
@@ -74,84 +60,24 @@ class Application(tornado.web.Application):
74
60
  for name,component in _.databases.items():
75
61
  await component.close()
76
62
 
77
- async def __async_init(self, **kwds):
78
- # check if a sessions cache was specified
79
- _.sessions = _.config.get(_.name, 'sessions', fallback=None)
80
- if _.sessions:
81
- logging.debug('Sessions cache: %s', _.sessions)
82
- try:
83
- _.sessions = _.caches[_.sessions]
84
- except KeyError:
85
- raise _.error('Unknown sessions cache instance: %s', _.sessions)
86
-
87
- self.settings['static_path'] = _.paths('static')
88
- self.settings['template_path'] = _.paths('templates')
89
- self.settings['debug'] = _.args.debug
90
-
63
+ async def _async_init(self, **kwds):
91
64
  # call the underscore application's entry point
92
65
  try:
93
66
  await _.wait(self.initialize())
94
67
  except NotImplementedError:
95
68
  logging.warning('No "initialize" function defined')
96
69
 
97
- if 'cookie_secret' not in self.settings:
98
- self.settings['cookie_secret'] = await self.cookie_secret()
99
-
100
- patterns = list(self._records_patterns)
101
-
102
- if self._login_patterns:
103
- self._login_patterns += [
104
- ( r'/login', _.logins.LoginPage ),
105
- ( r'/logout', _.logins.Logout ),
106
- ]
107
- self.settings['login_url'] = '/login'
108
- patterns += self._login_patterns
109
- patterns += self.patterns
110
-
111
- patterns.append(
112
- ( r'/(favicon.ico)', tornado.web.StaticFileHandler, {'path':''}),
113
- )
114
-
115
- if _.args.debug:
116
- for (pattern,cls,*params) in patterns:
117
- handler = f'{cls.__module__}.{cls.__name__}'
118
- logging.debug('%-32s %s %s', pattern, handler, params[0] if params else '')
119
-
120
- await self.__listen(patterns)
70
+ # block until the stop event is set
121
71
  await self._stop_event.wait()
72
+ # call clean-up code
122
73
  await _.wait(self.on_stop())
123
74
 
124
- async def __listen(self, patterns, **kwds):
125
- # call the Tornado Application init here to give children a chance
126
- # to initialize patterns and settings
127
- super().__init__(patterns, **self.settings)
128
-
129
- if 'xheaders' not in kwds:
130
- kwds['xheaders'] = True
131
-
132
- try:
133
- self.listen(_.args.port, _.args.address, **kwds)
134
- except Exception as e:
135
- raise _.error('%s', e) from None
136
-
137
- logging.info('Listening on %s:%d', _.args.address, _.args.port)
138
-
139
- def _record_handler(self, name, cls):
140
- self._records_patterns.append(
141
- (f'/{name}/{cls._name}(?:/(.*))?', cls)
142
- )
143
-
144
- def _login_handler(self, name, cls):
145
- self._login_patterns.append(
146
- (f'/{name}/{cls._name}', cls)
147
- )
148
-
149
75
  async def initialize(self):
150
- 'underscore apps should override this function'
76
+ '''underscore app entry point'''
151
77
  raise NotImplementedError
152
78
 
153
79
  async def logging(self):
154
- 'underscore apps can override or extend this function'
80
+ '''underscore apps can override or extend this function'''
155
81
 
156
82
  # add the handlers to the logger
157
83
  if _.config.getboolean(_.name, 'logging', fallback=False):
@@ -167,14 +93,12 @@ class Application(tornado.web.Application):
167
93
  root_logger = logging.getLogger()
168
94
  root_logger.addHandler(file_logger)
169
95
 
170
- async def cookie_secret(self):
171
- 'underscore apps can override this function'
172
- if _.sessions is not None:
173
- return await _.wait(_.sessions.cookie_secret())
174
-
175
- async def on_login(self, handler, user, *args, **kwds):
176
- 'underscore apps should override this function if a login is specified'
177
- raise NotImplementedError
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
178
102
 
179
103
  def task(self, fn, *args, **kwds):
180
104
  async def _task():
@@ -182,7 +106,7 @@ class Application(tornado.web.Application):
182
106
  return asyncio.create_task(_task())
183
107
 
184
108
  def periodic(self, _timeout, fn, *args, **kwds):
185
- 'run a function or coroutine on a recurring basis'
109
+ '''run a function or coroutine on a recurring basis'''
186
110
  async def _periodic():
187
111
  while True:
188
112
  # bail if the stop event is set
@@ -190,10 +114,10 @@ class Application(tornado.web.Application):
190
114
  try:
191
115
  await asyncio.wait_for(self._stop_event.wait(), timeout=_timeout)
192
116
  break
193
- except asyncio.TimeoutError as e:
117
+ except asyncio.TimeoutError:
194
118
  pass
195
119
  try:
196
- result = await _.wait(fn(*args, **kwds))
120
+ await _.wait(fn(*args, **kwds))
197
121
  except Exception as e:
198
122
  logging.exception(e)
199
123
  return asyncio.create_task(_periodic())
@@ -205,8 +129,16 @@ class Application(tornado.web.Application):
205
129
  def on_stop(self):
206
130
  pass
207
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
+
208
140
  def __signalHandler(self, signum, frame):
209
- 'handle signals in a thread-safe way'
141
+ '''handle signals in a thread-safe way, frame is None unless on Windows'''
210
142
  signame = signal.Signals(signum).name
211
143
  handler = getattr(self, f'on_{signame.lower()}', None)
212
144
  if handler:
@@ -219,3 +151,128 @@ class Application(tornado.web.Application):
219
151
  # and print newline after ^C on terminals
220
152
  def on_sigint(self, signum, frame):
221
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
+
@@ -10,6 +10,8 @@ import base64
10
10
  import binascii
11
11
  import functools
12
12
  import hashlib
13
+ import hmac
14
+ import os
13
15
  import urllib.parse
14
16
 
15
17
  import _
@@ -67,6 +69,13 @@ current_user = filter_user(lambda current_user: True)
67
69
  # control over access to component handlers
68
70
  protected = filter_user(lambda current_user: True)
69
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
+
70
79
 
71
80
  # implement old school user/name password prompt
72
81
  # useful for command-line tools to pass as part of the URL
@@ -76,8 +85,11 @@ def basic(realm='Authentication'):
76
85
  async def wrapper(self, *args, **kwds):
77
86
  auth = self.request.headers.get('Authorization', '')
78
87
  if auth.startswith('Basic '):
79
- auth = binascii.a2b_base64(auth[6:]).decode('utf-8')
80
- username,password = auth.split(':', 1)
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
81
93
  component = _.config.get(_.name, 'basic', fallback=None)
82
94
  if not component:
83
95
  raise _.HTTPError(500, 'No component specified for basic auth')
@@ -87,19 +99,65 @@ def basic(realm='Authentication'):
87
99
  raise _.HTTPError(500, 'Invalid component specified for basic auth')
88
100
  success = await login.check(username, password)
89
101
  if success:
90
- return method(self, *args, **kwds)
102
+ return await _.wait(method(self, *args, **kwds))
91
103
  self.set_status(401)
92
- self.set_header('WWW-Authenticate', f'Basic realm={realm}')
104
+ self.set_header('WWW-Authenticate', f'Basic realm="{realm}"')
93
105
  self.finish()
94
106
  return wrapper
95
107
  return basic_auth
96
108
 
97
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
+
98
158
  def simple_hash(value):
159
+ '''legacy unsalted hash, only kept to verify existing passwords'''
99
160
  value = value.encode('utf-8')
100
161
  for i in range(99999):
101
162
  value = hashlib.sha512(value).digest()
102
163
  return base64.b64encode(value).decode('ascii')
103
-
104
-
105
- __all__ = _.all()
@@ -11,7 +11,7 @@ import _
11
11
 
12
12
  class Cache:
13
13
  @classmethod
14
- async def _(cls, name, **kwds):
14
+ async def _(cls, component_name, **kwds):
15
15
  # create a dynamic child class with kwds from the ini file
16
16
  try:
17
17
  members = dict(_.config['sessions'])
@@ -24,10 +24,10 @@ class Cache:
24
24
 
25
25
  # instantiate the derived class
26
26
  self = type(cls.__name__, (cls,), _.prefix(members))()
27
- _.caches[name] = self
28
- await self.init(name, **kwds)
27
+ _.caches[component_name] = self
28
+ await self.init(component_name, **kwds)
29
29
 
30
- async def init(self, **kwds):
30
+ async def init(self, component_name, **kwds):
31
31
  pass
32
32
 
33
33
  async def close(self):
@@ -37,12 +37,12 @@ class Cache:
37
37
  raise NotImplementedError
38
38
 
39
39
  def save_session(self, session):
40
- try:
41
- return session['session_id']
42
- except KeyError:
43
- raise _.error('No session_id defined in session')
40
+ raise NotImplementedError
41
+
42
+ def load_session(self, session_id):
43
+ raise NotImplementedError
44
44
 
45
- async def load_session(self, session_id):
45
+ def clear_session(self, session_id):
46
46
  raise NotImplementedError
47
47
 
48
48