pypomes-jwt 0.7.3__tar.gz → 0.7.8__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.
Potentially problematic release.
This version of pypomes-jwt might be problematic. Click here for more details.
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/PKG-INFO +1 -1
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/pyproject.toml +1 -1
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/src/pypomes_jwt/jwt_constants.py +5 -6
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/src/pypomes_jwt/jwt_data.py +80 -9
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/src/pypomes_jwt/jwt_pomes.py +114 -58
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/.gitignore +0 -0
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/LICENSE +0 -0
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/README.md +0 -0
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/src/__init__.py +0 -0
- {pypomes_jwt-0.7.3 → pypomes_jwt-0.7.8}/src/pypomes_jwt/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.8
|
|
4
4
|
Summary: A collection of Python pomes, penyeach (JWT module)
|
|
5
5
|
Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-JWT
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-JWT/issues
|
|
@@ -16,12 +16,10 @@ JWT_DB_USER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_USER")
|
|
|
16
16
|
JWT_DB_PWD: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_PWD")
|
|
17
17
|
JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # for Oracle, only
|
|
18
18
|
JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
|
|
19
|
-
JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
JWT_DB_COL_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_TOKEN",
|
|
24
|
-
def_value="token")
|
|
19
|
+
JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
|
|
20
|
+
JWT_DB_COL_ACCOUNT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_ACCOUNT")
|
|
21
|
+
JWT_DB_COL_HASH: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_HASH")
|
|
22
|
+
JWT_DB_COL_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_TOKEN")
|
|
25
23
|
# define the database engine
|
|
26
24
|
__db_engine: str | None = env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE")
|
|
27
25
|
if __db_engine:
|
|
@@ -58,6 +56,7 @@ JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX
|
|
|
58
56
|
def_value=86400)
|
|
59
57
|
JWT_ROTATE_TOKENS: Final[bool] = env_get_bool(key=f"{APP_PREFIX}_JWT_ROTATE_TOKENS",
|
|
60
58
|
def_value=True)
|
|
59
|
+
JWT_ACCOUNT_LIMIT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCOUNT_LIMIT")
|
|
61
60
|
|
|
62
61
|
# recommended: allow the encode and decode keys to be generated anew when app starts
|
|
63
62
|
__encoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_ENCODE_KEY")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import jwt
|
|
2
3
|
import requests
|
|
3
4
|
import string
|
|
@@ -9,8 +10,9 @@ from threading import Lock
|
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from .jwt_constants import (
|
|
12
|
-
JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY,
|
|
13
|
-
|
|
13
|
+
JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY, JWT_DECODING_KEY,
|
|
14
|
+
JWT_ROTATE_TOKENS, JWT_ACCOUNT_LIMIT, JWT_DB_ENGINE,
|
|
15
|
+
JWT_DB_TABLE, JWT_DB_COL_ACCOUNT, JWT_DB_COL_HASH, JWT_DB_COL_TOKEN
|
|
14
16
|
)
|
|
15
17
|
|
|
16
18
|
|
|
@@ -113,7 +115,7 @@ class JwtData:
|
|
|
113
115
|
with self.access_lock:
|
|
114
116
|
if account_id not in self.access_data:
|
|
115
117
|
self.access_data[account_id] = {
|
|
116
|
-
"
|
|
118
|
+
"reference-url": reference_url,
|
|
117
119
|
"access-max-age": access_max_age,
|
|
118
120
|
"refresh-max-age": refresh_max_age,
|
|
119
121
|
"grace-interval": grace_interval,
|
|
@@ -200,6 +202,7 @@ class JwtData:
|
|
|
200
202
|
# obtain new tokens
|
|
201
203
|
current_claims["jti"] = str_random(size=32,
|
|
202
204
|
chars=string.ascii_letters + string.digits)
|
|
205
|
+
current_claims["sub"] = account_id
|
|
203
206
|
current_claims["iss"] = account_data.get("reference-url")
|
|
204
207
|
|
|
205
208
|
# where is the JWT service provider ?
|
|
@@ -224,6 +227,8 @@ class JwtData:
|
|
|
224
227
|
# JWT service is being provided locally
|
|
225
228
|
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
226
229
|
current_claims["iat"] = just_now
|
|
230
|
+
if JWT_DEFAULT_ALGORITHM in []:
|
|
231
|
+
current_claims["kid"] = JWT_DECODING_KEY
|
|
227
232
|
|
|
228
233
|
# retrieve the refresh token associated with the account id
|
|
229
234
|
refresh_token: str | None = None
|
|
@@ -257,12 +262,10 @@ class JwtData:
|
|
|
257
262
|
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
258
263
|
# persist the new refresh token
|
|
259
264
|
if JWT_DB_ENGINE:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
JWT_DB_COL_TOKEN: refresh_token},
|
|
265
|
-
logger=logger)
|
|
265
|
+
# persist the refresh token
|
|
266
|
+
_jwt_persist_token(account_id=account_id,
|
|
267
|
+
jwt_token=refresh_token,
|
|
268
|
+
logger=logger)
|
|
266
269
|
if errors:
|
|
267
270
|
raise RuntimeError(" - ".join(errors))
|
|
268
271
|
|
|
@@ -340,3 +343,71 @@ def _jwt_request_token(errors: list[str],
|
|
|
340
343
|
errors.append(err_msg)
|
|
341
344
|
|
|
342
345
|
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _jwt_persist_token(account_id: str,
|
|
349
|
+
jwt_token: str,
|
|
350
|
+
logger: Logger = None) -> bool:
|
|
351
|
+
"""
|
|
352
|
+
Persist the given token, making sure that the account limit is adhered to.
|
|
353
|
+
|
|
354
|
+
:param jwt_token: the JWT token to persist
|
|
355
|
+
:param account_id: the account identification
|
|
356
|
+
:returns: *True* if token was persisted, *False* otherwise
|
|
357
|
+
"""
|
|
358
|
+
from pypomes_db import db_select, db_insert, db_delete
|
|
359
|
+
from .jwt_pomes import jwt_get_claims
|
|
360
|
+
|
|
361
|
+
# retrieve the account's tokens
|
|
362
|
+
errors: list[str] = []
|
|
363
|
+
recs: list[tuple[str]] = db_select(errors=errors,
|
|
364
|
+
sel_stmt=f"SELECT {JWT_DB_COL_HASH}, {JWT_DB_COL_TOKEN} FROM {JWT_DB_TABLE} ",
|
|
365
|
+
where_data={JWT_DB_COL_ACCOUNT: f"'{account_id}'"})
|
|
366
|
+
if not errors:
|
|
367
|
+
if logger:
|
|
368
|
+
logger.debug(msg=f"Read {len(recs)} token from storage for account '{account_id}'")
|
|
369
|
+
# remove the expired tokens
|
|
370
|
+
expired: list[str] = []
|
|
371
|
+
for rec in recs:
|
|
372
|
+
token: str = rec[1]
|
|
373
|
+
token_hash: str = rec[0]
|
|
374
|
+
op_errors: list[str] = []
|
|
375
|
+
token_claims: dict[str, Any] = jwt_get_claims(errors=op_errors,
|
|
376
|
+
token=token,
|
|
377
|
+
validate=False,
|
|
378
|
+
logger=logger)
|
|
379
|
+
if op_errors:
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
exp: int = token_claims["payload"]["exp"]
|
|
383
|
+
if exp < datetime.now(tz=timezone.utc).timestamp():
|
|
384
|
+
expired.append(token_hash)
|
|
385
|
+
|
|
386
|
+
if not errors:
|
|
387
|
+
# remove expired tokens from persistence
|
|
388
|
+
# ruff: noqa: SIM102
|
|
389
|
+
if expired:
|
|
390
|
+
if db_delete(errors=errors,
|
|
391
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
392
|
+
where_data={
|
|
393
|
+
JWT_DB_COL_ACCOUNT: f"'{account_id}'",
|
|
394
|
+
JWT_DB_COL_HASH: expired
|
|
395
|
+
},
|
|
396
|
+
logger=logger) is not None:
|
|
397
|
+
if logger:
|
|
398
|
+
logger.debug(msg=f"{len(expired)} tokens removed from storage")
|
|
399
|
+
if 0 < JWT_ACCOUNT_LIMIT <= len(recs) - len(expired):
|
|
400
|
+
errors.append("Maximum number of active sessions "
|
|
401
|
+
f"({JWT_ACCOUNT_LIMIT}) exceeded for account '{account_id}'")
|
|
402
|
+
# persist token
|
|
403
|
+
if not errors:
|
|
404
|
+
# ruff: noqa: S324
|
|
405
|
+
hasher = hashlib.new(name="md5")
|
|
406
|
+
hasher.update(jwt_token.encode())
|
|
407
|
+
token_hash: str = hasher.digest().decode()
|
|
408
|
+
db_insert(errors=errors,
|
|
409
|
+
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
410
|
+
insert_data={"ds_hash": token_hash,
|
|
411
|
+
"ds_token": jwt_token})
|
|
412
|
+
|
|
413
|
+
return len(errors) == 0
|
|
@@ -6,7 +6,7 @@ from typing import Any, Literal
|
|
|
6
6
|
from .jwt_constants import (
|
|
7
7
|
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
8
8
|
JWT_DEFAULT_ALGORITHM, JWT_DECODING_KEY,
|
|
9
|
-
JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_ACCOUNT
|
|
9
|
+
JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_ACCOUNT, JWT_DB_COL_TOKEN
|
|
10
10
|
)
|
|
11
11
|
from .jwt_data import JwtData
|
|
12
12
|
|
|
@@ -31,6 +31,51 @@ def jwt_needed(func: callable) -> callable:
|
|
|
31
31
|
return wrapper
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def jwt_verify_request(request: Request,
|
|
35
|
+
logger: Logger = None) -> Response:
|
|
36
|
+
"""
|
|
37
|
+
Verify wheher the HTTP *request* has the proper authorization, as per the JWT standard.
|
|
38
|
+
|
|
39
|
+
:param request: the request to be verified
|
|
40
|
+
:param logger: optional logger
|
|
41
|
+
:return: *None* if the request is valid, otherwise a *Response* object reporting the error
|
|
42
|
+
"""
|
|
43
|
+
# initialize the return variable
|
|
44
|
+
result: Response | None = None
|
|
45
|
+
|
|
46
|
+
if logger:
|
|
47
|
+
logger.debug(msg="Validate a JWT token")
|
|
48
|
+
err_msg: str | None = None
|
|
49
|
+
|
|
50
|
+
# retrieve the authorization from the request header
|
|
51
|
+
auth_header: str = request.headers.get("Authorization")
|
|
52
|
+
|
|
53
|
+
# was a 'Bearer' authorization obtained ?
|
|
54
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
55
|
+
# yes, extract and validate the JWT access token
|
|
56
|
+
token: str = auth_header.split(" ")[1]
|
|
57
|
+
if logger:
|
|
58
|
+
logger.debug(msg=f"Token is '{token}'")
|
|
59
|
+
errors: list[str] = []
|
|
60
|
+
jwt_validate_token(errors=errors,
|
|
61
|
+
nature="A",
|
|
62
|
+
token=token)
|
|
63
|
+
if errors:
|
|
64
|
+
err_msg = "; ".join(errors)
|
|
65
|
+
else:
|
|
66
|
+
# no 'Bearer' found, report the error
|
|
67
|
+
err_msg = "Request header has no 'Bearer' data"
|
|
68
|
+
|
|
69
|
+
# log the error and deny the authorization
|
|
70
|
+
if err_msg:
|
|
71
|
+
if logger:
|
|
72
|
+
logger.error(msg=err_msg)
|
|
73
|
+
result = Response(response="Authorization failed",
|
|
74
|
+
status=401)
|
|
75
|
+
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
34
79
|
def jwt_assert_access(account_id: str) -> bool:
|
|
35
80
|
"""
|
|
36
81
|
Determine whether access for *account_id* has been established.
|
|
@@ -227,15 +272,28 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
227
272
|
logger.debug(msg=f"Retrieve JWT token data for '{account_id}'")
|
|
228
273
|
op_errors: list[str] = []
|
|
229
274
|
if refresh_token:
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
op_errors
|
|
275
|
+
# verify whether this refresh token is legitimate
|
|
276
|
+
if JWT_DB_ENGINE:
|
|
277
|
+
from pypomes_db import db_select
|
|
278
|
+
recs: list[tuple[str]] = db_select(errors=op_errors,
|
|
279
|
+
sel_stmt=f"SELECT {JWT_DB_COL_TOKEN} "
|
|
280
|
+
f"FROM {JWT_DB_TABLE}",
|
|
281
|
+
where_data={JWT_DB_COL_ACCOUNT: f"'{account_id}'"},
|
|
282
|
+
logger=logger)
|
|
283
|
+
if not op_errors and \
|
|
284
|
+
(len(recs) == 0 or recs[0][0] != refresh_token):
|
|
285
|
+
op_errors.append("Invalid refresh token")
|
|
286
|
+
if not op_errors:
|
|
287
|
+
account_claims = jwt_get_claims(errors=op_errors,
|
|
288
|
+
token=refresh_token)
|
|
289
|
+
if not op_errors and account_claims.get("nat") != "R":
|
|
290
|
+
op_errors.append("Invalid parameters")
|
|
234
291
|
|
|
235
292
|
if not op_errors:
|
|
236
293
|
try:
|
|
237
294
|
result = __jwt_data.issue_tokens(account_id=account_id,
|
|
238
|
-
account_claims=account_claims
|
|
295
|
+
account_claims=account_claims,
|
|
296
|
+
logger=logger)
|
|
239
297
|
if logger:
|
|
240
298
|
logger.debug(msg=f"Data is '{result}'")
|
|
241
299
|
except Exception as e:
|
|
@@ -253,12 +311,43 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
253
311
|
|
|
254
312
|
def jwt_get_claims(errors: list[str] | None,
|
|
255
313
|
token: str,
|
|
314
|
+
validate: bool = True,
|
|
256
315
|
logger: Logger = None) -> dict[str, Any]:
|
|
257
316
|
"""
|
|
258
317
|
Obtain and return the claims set of a JWT *token*.
|
|
259
318
|
|
|
319
|
+
If *validate* is set to *True*, tha following pieces of information are verified:
|
|
320
|
+
- the token was issued and signed by the local provider, and is not corrupted
|
|
321
|
+
- the claim 'exp' is present and is in the future
|
|
322
|
+
- the claim 'nbf' is present and is in the past
|
|
323
|
+
|
|
324
|
+
Structure of the returned data:
|
|
325
|
+
{
|
|
326
|
+
"header": {
|
|
327
|
+
"alg": "HS256",
|
|
328
|
+
"typ": "JWT"
|
|
329
|
+
},
|
|
330
|
+
"payload": {
|
|
331
|
+
"birthdate": "1980-01-01",
|
|
332
|
+
"email": "jdoe@mail.com",
|
|
333
|
+
"exp": 1516640454,
|
|
334
|
+
"iat": 1516239022,
|
|
335
|
+
"iss": "https://my_id_provider/issue",
|
|
336
|
+
"jti": "Uhsdfgr67FGH567qwSDF33er89retert",
|
|
337
|
+
"gender": "M,
|
|
338
|
+
"name": "John Doe",
|
|
339
|
+
"nbt": 1516249022
|
|
340
|
+
"sub": "1234567890",
|
|
341
|
+
"roles": [
|
|
342
|
+
"administrator",
|
|
343
|
+
"operator"
|
|
344
|
+
]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
260
348
|
:param errors: incidental error messages
|
|
261
349
|
:param token: the token to be inspected for claims
|
|
350
|
+
:param validate: If *True*, verifies the token's data
|
|
262
351
|
:param logger: optional logger
|
|
263
352
|
:return: the token's claimset, or *None* if error
|
|
264
353
|
"""
|
|
@@ -269,14 +358,26 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
269
358
|
logger.debug(msg=f"Retrieve claims for token '{token}'")
|
|
270
359
|
|
|
271
360
|
try:
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
361
|
+
# retrieve the token's payload
|
|
362
|
+
if validate:
|
|
363
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
364
|
+
options={
|
|
365
|
+
"verify_signature": True,
|
|
366
|
+
"verify_exp": True,
|
|
367
|
+
"verify_nbf": True
|
|
368
|
+
},
|
|
369
|
+
key=JWT_DECODING_KEY,
|
|
370
|
+
require=["exp", "nbf"],
|
|
371
|
+
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
278
372
|
else:
|
|
279
|
-
|
|
373
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
374
|
+
options={"verify_signature": False})
|
|
375
|
+
# retrieve the token's header
|
|
376
|
+
header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
377
|
+
result = {
|
|
378
|
+
"header": header,
|
|
379
|
+
"payload": payload
|
|
380
|
+
}
|
|
280
381
|
except Exception as e:
|
|
281
382
|
if logger:
|
|
282
383
|
logger.error(msg=str(e))
|
|
@@ -284,48 +385,3 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
284
385
|
errors.append(str(e))
|
|
285
386
|
|
|
286
387
|
return result
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def jwt_verify_request(request: Request,
|
|
290
|
-
logger: Logger = None) -> Response:
|
|
291
|
-
"""
|
|
292
|
-
Verify wheher the HTTP *request* has the proper authorization, as per the JWT standard.
|
|
293
|
-
|
|
294
|
-
:param request: the request to be verified
|
|
295
|
-
:param logger: optional logger
|
|
296
|
-
:return: *None* if the request is valid, otherwise a *Response* object reporting the error
|
|
297
|
-
"""
|
|
298
|
-
# initialize the return variable
|
|
299
|
-
result: Response | None = None
|
|
300
|
-
|
|
301
|
-
if logger:
|
|
302
|
-
logger.debug(msg="Validate a JWT token")
|
|
303
|
-
err_msg: str | None = None
|
|
304
|
-
|
|
305
|
-
# retrieve the authorization from the request header
|
|
306
|
-
auth_header: str = request.headers.get("Authorization")
|
|
307
|
-
|
|
308
|
-
# was a 'Bearer' authorization obtained ?
|
|
309
|
-
if auth_header and auth_header.startswith("Bearer "):
|
|
310
|
-
# yes, extract and validate the JWT access token
|
|
311
|
-
token: str = auth_header.split(" ")[1]
|
|
312
|
-
if logger:
|
|
313
|
-
logger.debug(msg=f"Token is '{token}'")
|
|
314
|
-
errors: list[str] = []
|
|
315
|
-
jwt_validate_token(errors=errors,
|
|
316
|
-
nature="A",
|
|
317
|
-
token=token)
|
|
318
|
-
if errors:
|
|
319
|
-
err_msg = "; ".join(errors)
|
|
320
|
-
else:
|
|
321
|
-
# no 'Bearer' found, report the error
|
|
322
|
-
err_msg = "Request header has no 'Bearer' data"
|
|
323
|
-
|
|
324
|
-
# log the error and deny the authorization
|
|
325
|
-
if err_msg:
|
|
326
|
-
if logger:
|
|
327
|
-
logger.error(msg=err_msg)
|
|
328
|
-
result = Response(response="Authorization failed",
|
|
329
|
-
status=401)
|
|
330
|
-
|
|
331
|
-
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|