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.
_/handlers.py ADDED
@@ -0,0 +1,45 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import os
10
+
11
+ import tornado.web
12
+
13
+ import _
14
+
15
+
16
+ class Template(tornado.web.RequestHandler):
17
+ def initialize(self, template='index', **kwds):
18
+ self.kwds = kwds
19
+ self.template = template + '.html'
20
+
21
+ def get(self, template=None, *args):
22
+ template = template + '.html' if template else self.template
23
+ # the template name can come from the URL, so a missing one is not found
24
+ if not os.path.exists(os.path.join(self.get_template_path(), template)):
25
+ raise _.HTTPError(404)
26
+ self.render(template, extra=args, **self.kwds)
27
+
28
+
29
+ class Protected(Template):
30
+ async def prepare(self):
31
+ if _.sessions is None:
32
+ raise _.HTTPError(500, 'No session component specified')
33
+
34
+ self.session = None
35
+ session_id = self.get_signed_cookie('session_id', max_age_days=1)
36
+ if session_id:
37
+ session_id = session_id.decode('utf-8')
38
+ self.session = await _.wait(_.sessions.load_session(session_id))
39
+
40
+ def get_current_user(self):
41
+ return self.session
42
+
43
+ @_.auth.current_user
44
+ def get(self, template=None, *args):
45
+ super().get(template, *args)
_/logins/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+
2
+ import urllib
3
+
4
+ import tornado.web
5
+
6
+ import _
7
+
8
+
9
+ class Login(tornado.web.RequestHandler):
10
+ @classmethod
11
+ async def _(cls, component_name, **kwds):
12
+ # create a dynamic child class with kwds from the ini file
13
+ # add a reference to the component name accessible by the new type
14
+ members = _.prefix(kwds)
15
+ members['_component'] = component_name
16
+ login_cls = type(cls.__name__, (cls,), members)
17
+ try:
18
+ await login_cls.init(component_name, **kwds)
19
+ except TypeError as e:
20
+ raise _.error('%s', e)
21
+ _.logins[component_name] = login_cls
22
+ _.application._login_handler('login', login_cls)
23
+
24
+ @classmethod
25
+ async def init(cls, component_name, **kwds):
26
+ pass
27
+
28
+ @classmethod
29
+ async def args(cls, component_name):
30
+ pass
31
+
32
+ @classmethod
33
+ async def check(cls, username, password):
34
+ raise NotImplementedError
35
+
36
+ def initialize(self):
37
+ self.next_url = self.get_argument('next', '/')
38
+ self.redirect_uri = f'{self.request.protocol}://{self.request.host}/login/{self._component}?next={self.next_url}'
39
+
40
+ async def on_login_success(self, user):
41
+ fn = getattr(self.application, f'on_{self._component}_login_success', self.application.on_login_success)
42
+ try:
43
+ session = await _.wait(fn(self, user))
44
+ await _.wait(_.sessions.save_session(session))
45
+ self.set_signed_cookie('session_id', session['session_id'], expires_days=1)
46
+ except NotImplementedError:
47
+ raise _.HTTPError(500, 'on_login_success method not implemented') from None
48
+ self.redirect(self.next_url)
49
+
50
+ async def on_login_failure(self, message='Invalid Login'):
51
+ fn = getattr(self.application, f'on_{self._component}_login_failure', self.application.on_login_failure)
52
+ await _.wait(fn(self._component))
53
+
54
+ url = self.get_login_url()
55
+ url += "?" + urllib.parse.urlencode(dict(message=message, next_url=self.next_url))
56
+ self.clear_cookie('session_id')
57
+ self.redirect(url)
58
+
59
+
60
+ class LoginPage(tornado.web.RequestHandler):
61
+ def initialize(self, template='login', **kwds):
62
+ self.kwds = kwds
63
+ self.template = template + '.html'
64
+
65
+ def get(self, template=None):
66
+ template = template + '.html' if template else self.template
67
+ message = self.get_argument('message', None)
68
+ next_url = self.get_argument('next', '/')
69
+ self.render(template, message=message, next_url=next_url, **self.kwds)
70
+
71
+
72
+ class Logout(tornado.web.RequestHandler):
73
+ async def get(self):
74
+ session_id = self.get_signed_cookie('session_id', max_age_days=1)
75
+
76
+ if session_id:
77
+ session_id = session_id.decode('utf-8')
78
+ await _.wait(self.application.on_logout(session_id))
79
+ self.session = await _.wait(_.sessions.clear_session(session_id))
80
+
81
+ self.clear_cookie('session_id')
82
+ self.redirect(self.get_argument('next', '/'))
_/logins/dblogin.py ADDED
@@ -0,0 +1,230 @@
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 json
11
+ import logging
12
+ import typing
13
+
14
+ import sqlalchemy
15
+
16
+ import _
17
+ import _.records
18
+
19
+
20
+ class DbLogin(_.logins.Login):
21
+ _table = 'users'
22
+ _username = 'username'
23
+ _password = 'password'
24
+
25
+ @classmethod
26
+ async def init(cls, component_name, database=None, table=None, username=None, password=None, **kwds):
27
+ cls._database = database
28
+ if database is None:
29
+ if 1 == len(_.databases):
30
+ cls._database = list(_.databases.keys())[0]
31
+ else:
32
+ raise _.error('dblogin requires a database to be specified')
33
+ try:
34
+ cls._db = _.databases[cls._database]
35
+ except AttributeError:
36
+ raise _.error('No database specified for %s', component_name)
37
+
38
+ # if only one login specified use short argument
39
+ prefix = f'{component_name}-' if len(_.config['logins']) > 1 else ''
40
+
41
+ _.argparser.add_argument(f'--{prefix}add-user',
42
+ metavar='<arg>', nargs=2,
43
+ help='create or update user with password'
44
+ )
45
+
46
+ _.argparser.add_argument(f'--{prefix}list-users',
47
+ action='store_true',
48
+ help='list users'
49
+ )
50
+
51
+ # create the dblogin table
52
+ annotations = {
53
+ cls._username : typing.Optional[str],
54
+ cls._password : typing.Optional[str],
55
+ }
56
+ columns = {
57
+ '__tablename__' : cls._table,
58
+ '__annotations__' : annotations,
59
+ '__primary_key__' : cls._username,
60
+ cls._username : sqlalchemy.orm.mapped_column(primary_key=True, init=False),
61
+ cls._password : sqlalchemy.orm.mapped_column(init=False),
62
+ }
63
+
64
+ for col,dbtype in kwds.items():
65
+ if not dbtype:
66
+ dbtype = 'str'
67
+ annotations[col] = typing.Optional[__builtins__.get(dbtype)]
68
+ columns[col] = sqlalchemy.orm.mapped_column(init=False)
69
+
70
+ table_cls = type(cls._table, (_.records.RecordsInterface,cls._db.Base,), columns)
71
+
72
+ cls._table = table_cls
73
+
74
+ members = {
75
+ 'component' : component_name,
76
+ 'db' : cls._db,
77
+ 'table' : cls._table,
78
+ 'username' : cls._username,
79
+ 'password' : cls._password,
80
+ }
81
+ subclass = type('DBLoginRecords', (DBLoginRecords,), _.prefix(members))
82
+ _.application._record_handler('logins', subclass)
83
+
84
+ @classmethod
85
+ async def args(cls, component_name):
86
+ # if only one login specified use short argument
87
+ prefix = f'{component_name}_' if len(_.config['logins']) > 1 else ''
88
+
89
+ add_user = getattr(_.args, f'{prefix}add_user')
90
+ if add_user:
91
+ username,password = add_user
92
+ password = _.auth.hash_password(password)
93
+
94
+ # the section is optional, it only adds extra columns
95
+ section = _.config[component_name] if _.config.has_section(component_name) else {}
96
+ record = dict((k,None) for k in section)
97
+ record.pop('database', None)
98
+ record.pop('table', None)
99
+
100
+ record[cls._username] = username
101
+ record[cls._password] = password
102
+
103
+ user = cls._table._from_dict(**record)
104
+
105
+ callback = getattr(_.application, f'on_{component_name}_add_user', None)
106
+ if callback is None:
107
+ callback = getattr(_.application, 'on_dblogin_add_user', None)
108
+ if callback:
109
+ await _.wait(callback(component_name, user))
110
+
111
+ await cls._db.upsert(user)
112
+ _.application.stop()
113
+
114
+ if getattr(_.args, f'{prefix}list_users'):
115
+ for user in await cls._db.find(cls._table):
116
+ user = user._as_dict()
117
+ user.pop(cls._password)
118
+ print(', '.join(f'{k}: {v}' for k,v in user.items()))
119
+ _.application.stop()
120
+
121
+ @classmethod
122
+ async def check(cls, username, password):
123
+ try:
124
+ record = await cls._db.find_one(cls._table, username)
125
+ except _.error as e:
126
+ logging.warning('%s', e)
127
+ record = None
128
+
129
+ if record is None:
130
+ logging.warning('No user: %s', username)
131
+ return None
132
+
133
+ stored = record[cls._password]
134
+ # hashing is CPU bound so keep it off the event loop
135
+ if not await asyncio.to_thread(_.auth.verify_password, password, stored, username):
136
+ logging.warning('Bad password: %s', username)
137
+ return None
138
+
139
+ # transparently upgrade legacy or weaker hashes
140
+ if _.auth.needs_rehash(stored):
141
+ record[cls._password] = await asyncio.to_thread(_.auth.hash_password, password)
142
+ await cls._db.upsert(record)
143
+
144
+ return record
145
+
146
+ async def post(self):
147
+ username = self.get_argument('username', None)
148
+ password = self.get_argument('password', None)
149
+
150
+ if username is None or password is None:
151
+ raise _.HTTPError(500)
152
+
153
+ user = await self.check(username, password)
154
+ if user:
155
+ await self.on_login_success(user)
156
+ else:
157
+ await self.on_login_failure()
158
+
159
+
160
+ class DBLoginRecords(_.handlers.Protected):
161
+ # READ
162
+ @_.auth.protected
163
+ async def get(self, username=None):
164
+ if username:
165
+ record = await self._db.find_one(self._table, username)
166
+ record = record._as_dict()
167
+ record.pop(self._password, None)
168
+ self.write(record)
169
+ else:
170
+ records = await self._db.find(self._table)
171
+ data = []
172
+ for record in records:
173
+ record = record._as_dict()
174
+ record.pop(self._password, None)
175
+ data.append(record)
176
+ self.write({'data':data})
177
+
178
+ # UPDATE
179
+ @_.auth.protected
180
+ async def put(self, username=None):
181
+ try:
182
+ data = json.loads(self.request.body)
183
+ except json.decoder.JSONDecodeError:
184
+ raise _.HTTPError(500)
185
+
186
+ username = data.get(self._username, None)
187
+ password = data.pop(self._password, None)
188
+ if not username or not password:
189
+ raise _.HTTPError(500)
190
+
191
+ entry = dict(_.config[self._component])
192
+ entry.pop('database', None)
193
+ entry.pop('table', None)
194
+ prune = list(entry.keys()) + [self._username, self._password]
195
+
196
+ for key in list(data.keys()):
197
+ if key not in prune:
198
+ data.pop(key)
199
+
200
+ user = self._table()
201
+ user(**data)
202
+
203
+ callback = getattr(_.application, f'on_{self._component}_add_user', None)
204
+ if callback is None:
205
+ callback = getattr(_.application, 'on_dblogin_add_user', None)
206
+ if callback:
207
+ await _.wait(callback(self._component, user))
208
+
209
+ if not user[self._password]:
210
+ password = await asyncio.to_thread(_.auth.hash_password, password)
211
+ user[self._password] = password
212
+
213
+ await self._db.insert(user)
214
+ self.set_status(204)
215
+
216
+ # DELETE
217
+ @_.auth.protected
218
+ async def delete(self, username=None):
219
+ self.set_status(204)
220
+ if username is None:
221
+ return
222
+
223
+ user = await self._db.find_one(self._table, username)
224
+ await self._db.delete(user)
225
+
226
+ callback = getattr(_.application, f'on_{self._component}_delete', None)
227
+ if callback is None:
228
+ callback = getattr(_.application, 'on_dblogin_delete', None)
229
+ if callback:
230
+ await _.wait(callback(self._component, username))
_/logins/facebook.py ADDED
@@ -0,0 +1,40 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import datetime
10
+
11
+ import _
12
+
13
+ import tornado.auth
14
+ import tornado.web
15
+
16
+
17
+ class Facebook(_.logins.Login, tornado.auth.FacebookGraphMixin):
18
+ @classmethod
19
+ async def init(cls, component_name, **kwds):
20
+ cls.extra = {"scope": "read_stream,offline_access"}
21
+
22
+ async def get(self):
23
+ try:
24
+ code = self.get_argument('code', None)
25
+ if not code:
26
+ self.authorize_redirect(
27
+ redirect_uri = self.redirect_uri,
28
+ client_id = self.client_id,
29
+ extra_params = self.extra
30
+ )
31
+ else:
32
+ user = await self.get_authenticated_user(
33
+ redirect_uri = self.redirect_uri,
34
+ client_id = self.client_id,
35
+ client_secret = self.client_secret,
36
+ code = code,
37
+ )
38
+ await self.on_login_success(user)
39
+ except tornado.httpclient.HTTPClientError as e:
40
+ await self.on_login_failure(str(e))
_/logins/gitlab.py ADDED
@@ -0,0 +1,58 @@
1
+ #
2
+ # (c) 2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ # This is for logging into GitLab servers and serves as an example
10
+ # of implementing an OAuth2 handler for any service that supports it
11
+
12
+ # For admins go to http(s)://your.gitlab.server/admin/applications and click "New application"
13
+ # For users go to http(s)://your.gitlab.server/-/profile/applications
14
+ # For groups go to http(s)://your.gitlab.server/groups/(group)/-/settings/applications
15
+ #
16
+ # Pick a "Name" for the application
17
+ #
18
+ # Set the "Redirect URI" to http(s)://your.underscore.app/login/gitlab
19
+ # (or replace gitlab at the end with whatever unique name assigned in the ini file)
20
+ #
21
+ # Select Trusted and Confidential
22
+ #
23
+ # "read_user" is the only scope required by this login plugin
24
+ #
25
+ # Click "Save application"
26
+ #
27
+ # Copy "Application ID" as client_id and copy "Secret" as client_secret in the ini
28
+ #
29
+ # Click "Continue"
30
+ #
31
+ # Your underscore app can now authenticate via your GitLab server
32
+ #
33
+
34
+ # [logins]
35
+ # gitlab
36
+ #
37
+ # [gitlab]
38
+ # gitlab = https://your.gitlab.server
39
+ # client_id = <client_id>
40
+ # client_secret = <client_secret>
41
+
42
+
43
+ import _
44
+
45
+ from .oauth2 import OAuth2
46
+
47
+
48
+ class GitLab(OAuth2, _.logins.Login):
49
+ @classmethod
50
+ async def init(cls, component_name, gitlab, **kwds):
51
+ cls.scope = ['read_user']
52
+
53
+ cls._OAUTH_AUTHORIZE_URL = f'{gitlab}/oauth/authorize'
54
+ cls._OAUTH_ACCESS_TOKEN_URL = f'{gitlab}/oauth/token'
55
+ cls._OAUTH_USERINFO_URL = f'{gitlab}/api/v4/user'
56
+ cls._OAUTH_SETTINGS_KEY = f'{component_name}_oauth'
57
+
58
+ await super().init(component_name, **kwds)
_/logins/google.py ADDED
@@ -0,0 +1,20 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import _
10
+
11
+ from .oauth2 import OAuth2
12
+
13
+
14
+ class Google(OAuth2, _.logins.Login):
15
+ @classmethod
16
+ async def init(cls, component_name, **kwds):
17
+ cls.scope = ['email']
18
+ cls.extra = {'approval_prompt': 'auto'}
19
+
20
+ await super().init(component_name, **kwds)
_/logins/htpasswd.py ADDED
@@ -0,0 +1,47 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import os
10
+ import hashlib
11
+ import base64
12
+
13
+ import _
14
+
15
+
16
+ # this is opening the file and scanning it for every login, which is fine
17
+ # for small projects with a limited number of users.
18
+ #
19
+ # Passwords can be managed with Apache's htpasswd program
20
+
21
+
22
+ class Htpasswd(_.logins.Login):
23
+ async def post(self):
24
+ username = self.get_argument('username', '')
25
+ password = self.get_argument('password', '')
26
+ password = password.encode('utf-8')
27
+ password = b'{SHA}' + base64.b64encode(hashlib.sha1(password).digest())
28
+ password = password.decode('ascii')
29
+
30
+ path = _.paths(_.config.get('simple', 'path'))
31
+
32
+ fp = open(path, 'r')
33
+ for line in fp:
34
+ if not line:
35
+ continue
36
+ entry,hash = line.split(':', 1)
37
+
38
+ if entry != username:
39
+ continue
40
+
41
+ if hash.rstrip() != password:
42
+ break
43
+
44
+ await self.on_login_success(user)
45
+ return
46
+
47
+ await self.on_login_failure()
_/logins/oauth2.py ADDED
@@ -0,0 +1,52 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import tornado.auth
10
+ import tornado.httpclient
11
+
12
+ import _
13
+
14
+
15
+ # for Google and Gitlab the GoogleOAuth2Mixin is a good starting point
16
+ class OAuth2(tornado.auth.GoogleOAuth2Mixin):
17
+ extra = None
18
+
19
+ @classmethod
20
+ async def init(cls, component_name, client_id, client_secret, **kwds):
21
+ _.application.settings[cls._OAUTH_SETTINGS_KEY] = {
22
+ 'key' : client_id,
23
+ 'secret' : client_secret,
24
+ }
25
+
26
+ async def get(self):
27
+ code = self.get_argument('code', None)
28
+ if not code:
29
+ # make the initial request to the OAuth2 service
30
+ self.authorize_redirect(
31
+ redirect_uri = self.redirect_uri,
32
+ client_id = self._client_id,
33
+ scope = self.scope,
34
+ response_type = 'code',
35
+ extra_params = self.extra
36
+ )
37
+ else:
38
+ try:
39
+ # the OAuth2 service will callback with a code
40
+ # pass it to the get_authenticated_user function defined
41
+ # in GitLabAuthMixin
42
+ self.oauth = await self.get_authenticated_user(
43
+ redirect_uri = self.redirect_uri,
44
+ code = code,
45
+ )
46
+ user = await self.oauth2_request(
47
+ self._OAUTH_USERINFO_URL,
48
+ access_token = self.oauth["access_token"]
49
+ )
50
+ await self.on_login_success(user)
51
+ except tornado.httpclient.HTTPClientError as e:
52
+ await self.on_login_failure(str(e))
_/logins/slap.py ADDED
@@ -0,0 +1,56 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import time
10
+ import logging
11
+
12
+ import tornado.escape
13
+
14
+ import _
15
+
16
+
17
+ try:
18
+ import ldap
19
+ except ImportError:
20
+ raise _.error('Missing LDAP module')
21
+
22
+
23
+ class Slap(_.logins.Login):
24
+ @classmethod
25
+ async def init(cls, component_name, **kwds):
26
+ _.argparser.add_argument(f'--{component_name}-list-users',
27
+ action='store_true',
28
+ help='list users'
29
+ )
30
+
31
+ @classmethod
32
+ async def check(cls, username, password):
33
+ try:
34
+ dn = cls.dn.format(username)
35
+ ldap_server = ldap.initialize(cls.uri)
36
+ ldap_server.bind_s(dn, password)
37
+ ldap_server.unbind()
38
+ return True
39
+ except ldap.NO_SUCH_OBJECT:
40
+ logging.warn('Could not find record for user: %s', username)
41
+ except ldap.INVALID_CREDENTIALS:
42
+ logging.warn('Invalid credentials for user: %s', username)
43
+ except ldap.SERVER_DOWN:
44
+ logging.error('Could not connect to LDAP server: %s', cls.uri)
45
+ return None
46
+
47
+ async def post(self):
48
+ username = self.get_argument('username', '')
49
+ username = tornado.escape.xhtml_escape(username)
50
+ password = self.get_argument('password', '')
51
+
52
+ ok = await self.check(username, password)
53
+ if ok:
54
+ await self.on_login_success({'username':username})
55
+ else:
56
+ await self.on_login_failure()
@@ -0,0 +1,21 @@
1
+ syntax = "proto3";
2
+
3
+ import "google/protobuf/descriptor.proto";
4
+
5
+
6
+ extend google.protobuf.MessageOptions {
7
+ string id = 50001;
8
+ bool no_table = 50002;
9
+ bool no_handler = 50003;
10
+ string refs = 50004;
11
+ }
12
+
13
+ extend google.protobuf.FieldOptions {
14
+ bool pkey = 50001;
15
+ bool no_column = 50002;
16
+ bool uniq = 50003;
17
+ bool required = 50004;
18
+ string ref = 50005;
19
+ string paired = 50006;
20
+ string subtype = 50007;
21
+ }