yhttp-auth 3.9.2__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.
@@ -0,0 +1,5 @@
1
+ from .cli import AuthenticatorCLI
2
+ from .install import install
3
+ from .authentication import Authenticator
4
+
5
+ __version__ = '3.9.2'
@@ -0,0 +1,397 @@
1
+ import os
2
+ import jwt
3
+ import hashlib
4
+ import functools
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ import redis
8
+ from pymlconf import MergableDict
9
+ from yhttp import statuses
10
+ from yhttp.lazyattribute import lazyattribute
11
+
12
+
13
+ FORBIDDEN_REDIS_KEY = 'yhttp-auth-forbidden'
14
+
15
+
16
+ class Identity:
17
+ def __init__(self, payload):
18
+ assert payload['id'] is not None
19
+ self.payload = payload
20
+
21
+ def __getattr__(self, attr):
22
+ try:
23
+ return self.payload[attr]
24
+ except KeyError:
25
+ raise AttributeError()
26
+
27
+ def authorize(self, *roles):
28
+ if 'roles' not in self.payload:
29
+ raise statuses.forbidden()
30
+
31
+ for r in roles:
32
+ if r in self.roles:
33
+ return r
34
+
35
+ raise statuses.forbidden()
36
+
37
+
38
+ class Authenticator:
39
+ redis = None
40
+ default_settings = MergableDict('''
41
+ redis:
42
+ host: localhost
43
+ port: 6379
44
+ db: 0
45
+
46
+ token:
47
+ algorithm: HS256
48
+ secret: foobar
49
+ maxage: 3600 # seconds
50
+ leeway: 10 # seconds
51
+
52
+ refresh:
53
+ key: yhttp-refresh-token
54
+ algorithm: HS256
55
+ secret: quxquux
56
+ secure: true
57
+ httponly: true
58
+ maxage: 2592000 # 1 Month
59
+ leeway: 10 # seconds
60
+ domain:
61
+ path:
62
+ samesite: Strict
63
+
64
+ csrf:
65
+ key: yhttp-csrf-token
66
+ secure: true
67
+ httponly: true
68
+ maxage: 60 # 1 Minute
69
+ samesite: Strict
70
+ domain:
71
+ path:
72
+
73
+ oauth2:
74
+ state:
75
+ algorithm: HS256
76
+ secret: quxquux
77
+ maxage: 60 # 1 Minute
78
+ leeway: 10 # seconds
79
+
80
+ ''')
81
+
82
+ def __init__(self, settings=None):
83
+ self.settings = settings if settings else self.default_settings
84
+ self.redis = redis.Redis(**self.settings.redis)
85
+
86
+ ##########
87
+ # OAuth2 #
88
+ ##########
89
+
90
+ @lazyattribute
91
+ def oauth2_state_maxage(self):
92
+ return self.settings.oauth2.state.maxage
93
+
94
+ @lazyattribute
95
+ def oauth2_state_secret(self):
96
+ return self.settings.oauth2.state.secret
97
+
98
+ @lazyattribute
99
+ def oauth2_state_algorithm(self):
100
+ return self.settings.oauth2.state.algorithm
101
+
102
+ @lazyattribute
103
+ def oauth2_state_leeway(self):
104
+ return self.settings.oauth2.state.leeway
105
+
106
+ def dump_oauth2_state(self, req, redirect_url, attrs=None):
107
+ payload = {
108
+ 'exp': self._exp(self.oauth2_state_maxage),
109
+ 'redurl': redirect_url,
110
+ 'id': self.create_csrftoken(req)
111
+ }
112
+ if attrs:
113
+ payload.update(attrs)
114
+
115
+ return jwt.encode(
116
+ payload,
117
+ self.oauth2_state_secret,
118
+ algorithm=self.oauth2_state_algorithm
119
+ )
120
+
121
+ def decode_oauth2_state(self, state):
122
+ return jwt.decode(
123
+ state,
124
+ self.oauth2_state_secret,
125
+ leeway=self.oauth2_state_leeway,
126
+ algorithms=[self.oauth2_state_algorithm]
127
+ )
128
+
129
+ def verify_oauth2_state(self, req, state):
130
+ if state is None:
131
+ raise statuses.unauthorized()
132
+
133
+ try:
134
+ identity = Identity(self.decode_oauth2_state(state))
135
+ except (KeyError, jwt.DecodeError, jwt.ExpiredSignatureError):
136
+ raise statuses.unauthorized()
137
+
138
+ self.verify_csrftoken(req, identity.id)
139
+ return identity
140
+
141
+ ########
142
+ # CSRF #
143
+ ########
144
+
145
+ @lazyattribute
146
+ def csrf_cookiekey(self):
147
+ return self.settings.csrf.key
148
+
149
+ def set_csrfcookie(self, req, token):
150
+ settings = self.settings.csrf
151
+
152
+ # Set cookie
153
+ entry = req.response.setcookie(self.csrf_cookiekey, token)
154
+
155
+ if settings.secure:
156
+ entry['secure'] = settings.secure
157
+
158
+ if settings.httponly:
159
+ entry['httponly'] = settings.httponly
160
+
161
+ if settings.domain:
162
+ entry['domain'] = settings.domain
163
+
164
+ if settings.samesite:
165
+ entry['samesite'] = settings.samesite
166
+
167
+ if settings.maxage:
168
+ entry['max-age'] = settings.maxage
169
+
170
+ entry['path'] = settings.path if settings.path else req.path
171
+ return entry
172
+
173
+ def create_csrftoken(self, req):
174
+ # Create a state token to prevent request forgery.
175
+ token = hashlib.sha256(os.urandom(1024)).hexdigest()
176
+
177
+ self.set_csrfcookie(req, token)
178
+ return token
179
+
180
+ def verify_csrftoken(self, req, token):
181
+ ctoken = req.cookies.get(self.csrf_cookiekey)
182
+ if ctoken is None:
183
+ raise statuses.unauthorized()
184
+
185
+ if ctoken.value != token:
186
+ raise statuses.unauthorized()
187
+
188
+ ##########
189
+ # Refresh
190
+ ##########
191
+
192
+ @lazyattribute
193
+ def refresh_cookiekey(self):
194
+ return self.settings.refresh.key
195
+
196
+ @lazyattribute
197
+ def refresh_secret(self):
198
+ return self.settings.refresh.secret
199
+
200
+ @lazyattribute
201
+ def refresh_maxage(self):
202
+ return self.settings.refresh.maxage
203
+
204
+ @lazyattribute
205
+ def refresh_leeway(self):
206
+ return self.settings.refresh.leeway
207
+
208
+ @lazyattribute
209
+ def refresh_algorithm(self):
210
+ return self.settings.refresh.algorithm
211
+
212
+ def _set_refreshtoken(self, req, token):
213
+ settings = self.settings.refresh
214
+
215
+ # Set cookie
216
+ entry = req.response.setcookie(self.refresh_cookiekey, token)
217
+
218
+ if settings.secure:
219
+ entry['secure'] = settings.secure
220
+
221
+ if settings.httponly:
222
+ entry['httponly'] = settings.httponly
223
+
224
+ if settings.domain:
225
+ entry['domain'] = settings.domain
226
+
227
+ if settings.samesite:
228
+ entry['samesite'] = settings.samesite
229
+
230
+ entry['path'] = settings.path if settings.path else req.path
231
+ return entry
232
+
233
+ def delete_refreshtoken(self, req):
234
+ entry = self._set_refreshtoken(req, '')
235
+ entry['expires'] = 'Thu, 01 Jan 1970 00:00:00 GMT'
236
+ return entry
237
+
238
+ def set_refreshtoken(self, req, id, attrs=None):
239
+ settings = self.settings.refresh
240
+ token = self.dump_refreshtoken(id, attrs)
241
+
242
+ # Set cookie
243
+ entry = self._set_refreshtoken(req, token)
244
+ entry['max-age'] = settings.maxage
245
+ return entry
246
+
247
+ def _exp(self, seconds):
248
+ return datetime.now(tz=timezone.utc) + timedelta(seconds=seconds)
249
+
250
+ def dump_refreshtoken(self, id, attrs=None):
251
+ payload = {
252
+ 'id': id,
253
+ 'refresh': True,
254
+ 'exp': self._exp(self.refresh_maxage)
255
+ }
256
+ if attrs:
257
+ payload.update(attrs)
258
+
259
+ return jwt.encode(
260
+ payload,
261
+ self.refresh_secret,
262
+ algorithm=self.refresh_algorithm
263
+ )
264
+
265
+ def verify_refreshtoken(self, req):
266
+ if self.refresh_cookiekey not in req.cookies:
267
+ raise statuses.unauthorized()
268
+
269
+ token = req.cookies[self.refresh_cookiekey].value
270
+ try:
271
+ identity = Identity(jwt.decode(
272
+ token,
273
+ self.refresh_secret,
274
+ leeway=self.refresh_leeway,
275
+ algorithms=[self.refresh_algorithm]
276
+ ))
277
+
278
+ except (KeyError, jwt.DecodeError, jwt.ExpiredSignatureError):
279
+ raise statuses.unauthorized()
280
+
281
+ self.check_blacklist(identity.id)
282
+ return identity
283
+
284
+ def read_refreshtoken(self, req):
285
+ if self.refresh_cookiekey not in req.cookies:
286
+ return None
287
+
288
+ token = req.cookies[self.refresh_cookiekey].value
289
+ try:
290
+ identity = Identity(jwt.decode(
291
+ token,
292
+ options={"verify_signature": False},
293
+ ))
294
+
295
+ except (KeyError, jwt.DecodeError):
296
+ return None
297
+
298
+ return identity
299
+
300
+ #########
301
+ # Token #
302
+ #########
303
+
304
+ @lazyattribute
305
+ def token_secret(self):
306
+ return self.settings.token.secret
307
+
308
+ @lazyattribute
309
+ def token_maxage(self):
310
+ return self.settings.token.maxage
311
+
312
+ @lazyattribute
313
+ def token_leeway(self):
314
+ return self.settings.token.leeway
315
+
316
+ @lazyattribute
317
+ def token_algorithm(self):
318
+ return self.settings.token.algorithm
319
+
320
+ def dump(self, id, attrs=None, maxage=None):
321
+ payload = {
322
+ 'id': id,
323
+ 'exp': self._exp(maxage or self.token_maxage)
324
+ }
325
+ if attrs:
326
+ payload.update(attrs)
327
+
328
+ return jwt.encode(
329
+ payload,
330
+ self.token_secret,
331
+ algorithm=self.token_algorithm
332
+ )
333
+
334
+ def dump_from_refreshtoken(self, refresh, attrs=None):
335
+ payload = refresh.payload.copy()
336
+ del payload['refresh']
337
+
338
+ if attrs:
339
+ payload.update(attrs)
340
+
341
+ payload['exp'] = self._exp(self.token_maxage)
342
+ return jwt.encode(
343
+ payload,
344
+ self.token_secret,
345
+ algorithm=self.token_algorithm
346
+ )
347
+
348
+ def check_blacklist(self, userid):
349
+ # FIXME: use redis hash, hset, hget
350
+ if self.redis is not None and \
351
+ self.redis.sismember(FORBIDDEN_REDIS_KEY, userid):
352
+ raise statuses.unauthorized()
353
+
354
+ def decode_token(self, token):
355
+ return jwt.decode(
356
+ token,
357
+ self.token_secret,
358
+ leeway=self.token_leeway,
359
+ algorithms=[self.token_algorithm]
360
+ )
361
+
362
+ def verify_token(self, req):
363
+ token = req.headers.get('Authorization')
364
+
365
+ if token is None or not token.startswith('Bearer '):
366
+ raise statuses.unauthorized()
367
+
368
+ try:
369
+ identity = Identity(self.decode_token(token[7:]))
370
+ except (KeyError, jwt.DecodeError, jwt.ExpiredSignatureError):
371
+ raise statuses.unauthorized()
372
+
373
+ self.check_blacklist(identity.id)
374
+ return identity
375
+
376
+ def preventlogin(self, id):
377
+ self.redis.sadd(FORBIDDEN_REDIS_KEY, id)
378
+
379
+ def permitlogin(self, id):
380
+ self.redis.srem(FORBIDDEN_REDIS_KEY, id)
381
+
382
+
383
+ def authenticate(app, roles=None):
384
+ if isinstance(roles, str):
385
+ roles = [i.strip() for i in roles.split(',')]
386
+
387
+ def decorator(handler):
388
+ @functools.wraps(handler)
389
+ def wrapper(req, *args, **kw):
390
+ req.identity = app.auth.verify_token(req)
391
+ if roles is not None:
392
+ req.identity.authorize(*roles)
393
+
394
+ return handler(req, *args, **kw)
395
+
396
+ return wrapper
397
+ return decorator
yhttp/ext/auth/cli.py ADDED
@@ -0,0 +1,37 @@
1
+ from easycli import SubCommand, Argument
2
+ import json
3
+
4
+ from .authentication import Authenticator
5
+
6
+
7
+ class Create(SubCommand):
8
+ __command__ = 'create'
9
+ __aliases__ = ['c']
10
+ __arguments__ = [
11
+ Argument(
12
+ 'id', help='example: alice'
13
+ ),
14
+ Argument(
15
+ 'payload', default='', nargs='?', help='example: {"foo": "bar"}'
16
+ ),
17
+ Argument(
18
+ '--maxage', type=int, help='Token maxage in seconds.'
19
+ ),
20
+ ]
21
+
22
+ def __call__(self, args):
23
+ settings = args.application.settings.auth
24
+ jwt = Authenticator(settings)
25
+ if args.payload:
26
+ payload = json.loads(args.payload)
27
+ else:
28
+ payload = ''
29
+
30
+ print(jwt.dump(args.id, payload, maxage=args.maxage))
31
+
32
+
33
+ class AuthenticatorCLI(SubCommand):
34
+ __command__ = 'auth'
35
+ __arguments__ = [
36
+ Create,
37
+ ]
@@ -0,0 +1,16 @@
1
+ import functools
2
+
3
+ from .authentication import Authenticator, authenticate
4
+ from .cli import AuthenticatorCLI
5
+
6
+
7
+ def install(app):
8
+ app.cliarguments.append(AuthenticatorCLI)
9
+ app.settings.merge('auth: {}')
10
+ app.settings['auth'].merge(Authenticator.default_settings)
11
+
12
+ @app.when
13
+ def ready(app):
14
+ app.auth = Authenticator(app.settings.auth)
15
+
16
+ return functools.partial(authenticate, app)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 yhttp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.1
2
+ Name: yhttp-auth
3
+ Version: 3.9.2
4
+ Summary: A very micro http framework.
5
+ Home-page: http://github.com/yhttp/yhttp-auth
6
+ Author: Vahid Mardani
7
+ Author-email: vahid.mardani@gmail.com
8
+ License: MIT
9
+ Classifier: Environment :: Console
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: English
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: License :: Other/Proprietary License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Topic :: Software Development
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: pyjwt
26
+ Requires-Dist: hiredis
27
+ Requires-Dist: redis
28
+ Requires-Dist: yhttp >=4.1.4
29
+
30
+ # yhttp-auth
31
+
32
+ [![PyPI](http://img.shields.io/pypi/v/yhttp-auth.svg)](https://pypi.python.org/pypi/yhttp-auth)
33
+ [![Build](https://github.com/yhttp/yhttp-auth/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/yhttp/yhttp-auth/actions/workflows/build.yml)
34
+ [![Coverage Status](https://coveralls.io/repos/github/yhttp/yhttp-auth/badge.svg?branch=master)](https://coveralls.io/github/yhttp/yhttp-auth?branch=master)
35
+
36
+
37
+ Authentication extension for [yhttp](https://github.com/yhttp/yhttp).
38
+
39
+
40
+ ### Install
41
+
42
+ ```bash
43
+ pip install yhttp-pony
44
+ ```
45
+
46
+
47
+ ### Usage
48
+
49
+ ```python
50
+ from yhttp import Application
51
+ from yhttp.ext.auth import install as auth_install
52
+
53
+
54
+ app = Application()
55
+ auth = auth_install(app)
56
+ app.settings.merge(f'''
57
+ auth:
58
+ redis:
59
+ host: localhost
60
+ port: 6379
61
+ db: 0
62
+
63
+ token:
64
+ algorithm: HS256
65
+ secret: foobar
66
+
67
+ refresh:
68
+ key: yhttp-refresh-token
69
+ algorithm: HS256
70
+ secret: quxquux
71
+ secure: true
72
+ httponly: true
73
+ maxage: 2592000 # 1 Month
74
+ domain: example.com
75
+ ''')
76
+
77
+
78
+ @app.route('/reftokens')
79
+ @yhttp.statuscode(yhttp.statuses.created)
80
+ def create(req):
81
+ app.auth.set_refreshtoken(req, 'alice', dict(baz='qux'))
82
+
83
+ @app.route('/tokens')
84
+ @yhttp.statuscode(yhttp.statuses.created)
85
+ @yhttp.text
86
+ def refresh(req):
87
+ reftoken = app.auth.verify_refreshtoken(req)
88
+ return app.auth.dump_from_refreshtoken(reftoken, dict(foo='bar'))
89
+
90
+ @app.route('/admin')
91
+ @auth(roles='admin, god')
92
+ @yhttp.text
93
+ def get(req):
94
+ return req.identity.roles
95
+
96
+ app.ready()
97
+ ```
98
+
99
+ ### Command line interface
100
+
101
+ setup.py
102
+
103
+ ```python
104
+
105
+ setup(
106
+ ...
107
+ entry_points={
108
+ 'console_scripts': [
109
+ 'myapp = myapp:app.climain'
110
+ ]
111
+ },
112
+ ...
113
+ )
114
+
115
+ ```
116
+
117
+ ```bash
118
+ myapp auth --help
119
+ ```
@@ -0,0 +1,9 @@
1
+ yhttp/ext/auth/__init__.py,sha256=B84lEOXQReXYwKmMkVjDAAZIeFcPSJk9F-u2DaBa_Qs,128
2
+ yhttp/ext/auth/authentication.py,sha256=35x999VqUtbrnRLX3FV9CZ-qHmlgGeAwWmF1mBUXMqY,10229
3
+ yhttp/ext/auth/cli.py,sha256=EX0pgDLRIjm-ulPwZ7lbEqYpFXoCmaozzK6HFJQC9sQ,873
4
+ yhttp/ext/auth/install.py,sha256=EIDVHYHlvvqmU99nsxuRjSEUNprHyzbv4ybyt_RWXNw,408
5
+ yhttp_auth-3.9.2.dist-info/LICENSE,sha256=1lZ7neh4Es6earhYMwYaqsuS1Alohsrf9GZRVdl7l5o,1062
6
+ yhttp_auth-3.9.2.dist-info/METADATA,sha256=G3fecKi0_xXcr9vI-feBJJn2OcPIBMN2-QhWijbgBNQ,2785
7
+ yhttp_auth-3.9.2.dist-info/WHEEL,sha256=HiCZjzuy6Dw0hdX5R3LCFPDmFS4BWl8H-8W39XfmgX4,91
8
+ yhttp_auth-3.9.2.dist-info/top_level.txt,sha256=efaJ4DfUOs7MO3pjIEyRhnQASGVyD6vH-SL3ogQxkeU,6
9
+ yhttp_auth-3.9.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (72.2.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ yhttp