pypomes-jwt 0.7.6__py3-none-any.whl → 0.7.9__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.
Potentially problematic release.
This version of pypomes-jwt might be problematic. Click here for more details.
- pypomes_jwt/__init__.py +2 -2
- pypomes_jwt/jwt_constants.py +3 -3
- pypomes_jwt/jwt_data.py +162 -100
- pypomes_jwt/jwt_pomes.py +141 -84
- {pypomes_jwt-0.7.6.dist-info → pypomes_jwt-0.7.9.dist-info}/METADATA +1 -1
- pypomes_jwt-0.7.9.dist-info/RECORD +8 -0
- pypomes_jwt-0.7.6.dist-info/RECORD +0 -8
- {pypomes_jwt-0.7.6.dist-info → pypomes_jwt-0.7.9.dist-info}/WHEEL +0 -0
- {pypomes_jwt-0.7.6.dist-info → pypomes_jwt-0.7.9.dist-info}/licenses/LICENSE +0 -0
pypomes_jwt/__init__.py
CHANGED
|
@@ -8,7 +8,7 @@ from .jwt_constants import (
|
|
|
8
8
|
from .jwt_pomes import (
|
|
9
9
|
jwt_needed, jwt_verify_request,
|
|
10
10
|
jwt_get_tokens, jwt_get_claims, jwt_validate_token,
|
|
11
|
-
jwt_assert_access, jwt_set_access,
|
|
11
|
+
jwt_assert_access, jwt_set_access, jwt_remove_account, jwt_revoke_token
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
__all__ = [
|
|
@@ -21,7 +21,7 @@ __all__ = [
|
|
|
21
21
|
# jwt_pomes
|
|
22
22
|
"jwt_needed", "jwt_verify_request",
|
|
23
23
|
"jwt_get_tokens", "jwt_get_claims", "jwt_validate_token",
|
|
24
|
-
"jwt_assert_access", "jwt_set_access", "
|
|
24
|
+
"jwt_assert_access", "jwt_set_access", "jwt_remove_account", "jwt_revoke_token"
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
from importlib.metadata import version
|
pypomes_jwt/jwt_constants.py
CHANGED
|
@@ -3,7 +3,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
3
3
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
4
4
|
from pypomes_core import (
|
|
5
5
|
APP_PREFIX,
|
|
6
|
-
env_get_str, env_get_bytes, env_get_int
|
|
6
|
+
env_get_str, env_get_bytes, env_get_int
|
|
7
7
|
)
|
|
8
8
|
from secrets import token_bytes
|
|
9
9
|
from typing import Final
|
|
@@ -18,6 +18,7 @@ JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # fo
|
|
|
18
18
|
JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
|
|
19
19
|
JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
|
|
20
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")
|
|
21
22
|
JWT_DB_COL_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_TOKEN")
|
|
22
23
|
# define the database engine
|
|
23
24
|
__db_engine: str | None = env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE")
|
|
@@ -53,8 +54,7 @@ JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_A
|
|
|
53
54
|
# recommended: at least 2 hours (set to 24 hours)
|
|
54
55
|
JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX_AGE",
|
|
55
56
|
def_value=86400)
|
|
56
|
-
|
|
57
|
-
def_value=True)
|
|
57
|
+
JWT_ACCOUNT_LIMIT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCOUNT_LIMIT")
|
|
58
58
|
|
|
59
59
|
# recommended: allow the encode and decode keys to be generated anew when app starts
|
|
60
60
|
__encoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_ENCODE_KEY")
|
pypomes_jwt/jwt_data.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import jwt
|
|
2
3
|
import requests
|
|
3
4
|
import string
|
|
@@ -9,8 +10,8 @@ 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
|
-
JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_ACCOUNT, JWT_DB_COL_TOKEN
|
|
13
|
+
JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY, JWT_DECODING_KEY, JWT_ACCOUNT_LIMIT,
|
|
14
|
+
JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_ACCOUNT, JWT_DB_COL_HASH, JWT_DB_COL_TOKEN
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
|
|
@@ -19,30 +20,30 @@ class JwtData:
|
|
|
19
20
|
Shared JWT data for security token access.
|
|
20
21
|
|
|
21
22
|
Instance variables:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
23
|
+
- access_lock: lock for safe multi-threading access
|
|
24
|
+
- access_data: dictionary holding the JWT token data, organized by account id:
|
|
25
|
+
{
|
|
26
|
+
<account-id>: {
|
|
27
|
+
"reference-url": # the reference URL
|
|
28
|
+
"remote-provider": <bool>, # whether the JWT provider is a remote server
|
|
29
|
+
"request-timeout": <int>, # in seconds - defaults to no timeout
|
|
30
|
+
"access-max-age": <int>, # in seconds - defaults to JWT_ACCESS_MAX_AGE
|
|
31
|
+
"refresh-max-age": <int>, # in seconds - defaults to JWT_REFRESH_MAX_AGE
|
|
32
|
+
"grace-interval": <int> # time to wait for token to be valid, in seconds
|
|
33
|
+
"token-audience": <string> # the audience the token is intended for
|
|
34
|
+
"token_nonce": <string> # value used to associate a client session with a token
|
|
35
|
+
"claims": {
|
|
36
|
+
"birthdate": <string>, # subject's birth date
|
|
37
|
+
"email": <string>, # subject's email
|
|
38
|
+
"gender": <string>, # subject's gender
|
|
39
|
+
"name": <string>, # subject's name
|
|
40
|
+
"roles": <List[str]>, # subject roles
|
|
41
|
+
"nonce": <string>, # value used to associate a Client session with a token
|
|
42
|
+
...
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
...
|
|
46
|
+
}
|
|
46
47
|
|
|
47
48
|
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between
|
|
48
49
|
two parties. It is fully described in the RFC 7519, issued by the Internet Engineering Task Force
|
|
@@ -51,24 +52,30 @@ class JwtData:
|
|
|
51
52
|
as token-related and account-related. All times are UTC.
|
|
52
53
|
|
|
53
54
|
Token-related claims are mostly required claims, and convey information about the token itself:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
"exp": <timestamp> # expiration time
|
|
56
|
+
"iat": <timestamp> # issued at
|
|
57
|
+
"iss": <string> # issuer (for remote providers, URL to obtain and validate the access tokens)
|
|
58
|
+
"jti": <string> # JWT id
|
|
59
|
+
"sub": <string> # subject (the account identification)
|
|
60
|
+
"nat": <string> # nature of token (A: access; R: refresh) - locally issued tokens, only
|
|
61
|
+
# optional:
|
|
62
|
+
"aud": <string> # token audience
|
|
63
|
+
"nbt": <timestamp> # not before time
|
|
63
64
|
|
|
64
65
|
Account-related claims are optional claims, and convey information about the registered account they belong to.
|
|
65
66
|
Alhough they can be freely specified, these are some of the most commonly used claims:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
"birthdate": <string> # subject's birth date
|
|
68
|
+
"email": <string> # subject's email
|
|
69
|
+
"gender": <string> # subject's gender
|
|
70
|
+
"name": <string> # subject's name
|
|
71
|
+
"roles": <List[str]> # subject roles
|
|
72
|
+
"nonce": <string> # value used to associate a client session with a token
|
|
73
|
+
|
|
74
|
+
The token header has these items:
|
|
75
|
+
"alg": <string> # the algorithm used to sign the token (one of 'HS256', 'HS512', 'RSA256', 'RSA512')
|
|
76
|
+
"typ": <string> # the token type (fixed to 'JWT'
|
|
77
|
+
"kid": <string> # a reference to the encoding/decoding keys used
|
|
78
|
+
# (if issued by the local server, holds the public key, if assimetric keys were used)
|
|
72
79
|
"""
|
|
73
80
|
def __init__(self) -> None:
|
|
74
81
|
"""
|
|
@@ -77,18 +84,18 @@ class JwtData:
|
|
|
77
84
|
self.access_lock: Lock = Lock()
|
|
78
85
|
self.access_data: dict[str, Any] = {}
|
|
79
86
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
def add_account(self,
|
|
88
|
+
account_id: str,
|
|
89
|
+
reference_url: str,
|
|
90
|
+
claims: dict[str, Any],
|
|
91
|
+
access_max_age: int,
|
|
92
|
+
refresh_max_age: int,
|
|
93
|
+
grace_interval: int,
|
|
94
|
+
token_audience: str,
|
|
95
|
+
token_nonce: str,
|
|
96
|
+
request_timeout: int,
|
|
97
|
+
remote_provider: bool,
|
|
98
|
+
logger: Logger = None) -> None:
|
|
92
99
|
"""
|
|
93
100
|
Add to storage the parameters needed to produce and validate JWT tokens for *account_id*.
|
|
94
101
|
|
|
@@ -128,9 +135,9 @@ class JwtData:
|
|
|
128
135
|
elif logger:
|
|
129
136
|
logger.warning(f"JWT data already exists for '{account_id}'")
|
|
130
137
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
def remove_account(self,
|
|
139
|
+
account_id: str,
|
|
140
|
+
logger: Logger) -> bool:
|
|
134
141
|
"""
|
|
135
142
|
Remove from storage the access data for *account_id*.
|
|
136
143
|
|
|
@@ -142,6 +149,12 @@ class JwtData:
|
|
|
142
149
|
with self.access_lock:
|
|
143
150
|
account_data = self.access_data.pop(account_id, None)
|
|
144
151
|
|
|
152
|
+
if account_data and JWT_DB_ENGINE:
|
|
153
|
+
from pypomes_db import db_delete
|
|
154
|
+
db_delete(errors=None,
|
|
155
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
156
|
+
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
157
|
+
logger=logger)
|
|
145
158
|
if logger:
|
|
146
159
|
if account_data:
|
|
147
160
|
logger.debug(f"Removed JWT data for '{account_id}'")
|
|
@@ -200,6 +213,7 @@ class JwtData:
|
|
|
200
213
|
# obtain new tokens
|
|
201
214
|
current_claims["jti"] = str_random(size=32,
|
|
202
215
|
chars=string.ascii_letters + string.digits)
|
|
216
|
+
current_claims["sub"] = account_id
|
|
203
217
|
current_claims["iss"] = account_data.get("reference-url")
|
|
204
218
|
|
|
205
219
|
# where is the JWT service provider ?
|
|
@@ -224,55 +238,36 @@ class JwtData:
|
|
|
224
238
|
# JWT service is being provided locally
|
|
225
239
|
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
226
240
|
current_claims["iat"] = just_now
|
|
241
|
+
token_header: dict[str, Any] = None \
|
|
242
|
+
if JWT_DEFAULT_ALGORITHM not in ["RSA256", "RSA512"] \
|
|
243
|
+
else {"kid": JWT_DECODING_KEY}
|
|
227
244
|
|
|
228
|
-
#
|
|
229
|
-
refresh_token: str | None = None
|
|
230
|
-
if JWT_DB_ENGINE:
|
|
231
|
-
from pypomes_db import db_select, db_delete
|
|
232
|
-
if JWT_ROTATE_TOKENS:
|
|
233
|
-
db_delete(errors=errors,
|
|
234
|
-
delete_stmt=f"DELETE FROM {JWT_DB_TABLE} "
|
|
235
|
-
f"WHERE {JWT_DB_COL_ACCOUNT} = '{account_id}'",
|
|
236
|
-
logger=logger)
|
|
237
|
-
else:
|
|
238
|
-
recs: list[tuple[str]] = \
|
|
239
|
-
db_select(errors=errors,
|
|
240
|
-
sel_stmt=f"SELECT token FROM {JWT_DB_TABLE} "
|
|
241
|
-
f"WHERE {JWT_DB_COL_ACCOUNT} = '{account_id}'",
|
|
242
|
-
max_count=1,
|
|
243
|
-
logger=logger)
|
|
244
|
-
if recs:
|
|
245
|
-
refresh_token = recs[0][0]
|
|
246
|
-
if errors:
|
|
247
|
-
raise RuntimeError(" - ".join(errors))
|
|
248
|
-
|
|
249
|
-
# was it obtained ?
|
|
250
|
-
if not refresh_token:
|
|
251
|
-
# no, issue a new one
|
|
252
|
-
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
253
|
-
current_claims["nat"] = "R"
|
|
254
|
-
# may raise an exception
|
|
255
|
-
refresh_token: str = jwt.encode(payload=current_claims,
|
|
256
|
-
key=JWT_ENCODING_KEY,
|
|
257
|
-
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
258
|
-
# persist the new refresh token
|
|
259
|
-
if JWT_DB_ENGINE:
|
|
260
|
-
from pypomes_db import db_insert
|
|
261
|
-
db_insert(errors=errors,
|
|
262
|
-
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
263
|
-
insert_data={JWT_DB_COL_ACCOUNT: account_id,
|
|
264
|
-
JWT_DB_COL_TOKEN: refresh_token},
|
|
265
|
-
logger=logger)
|
|
266
|
-
if errors:
|
|
267
|
-
raise RuntimeError(" - ".join(errors))
|
|
268
|
-
|
|
269
|
-
# issue the access token
|
|
245
|
+
# issue the access token first
|
|
270
246
|
current_claims["nat"] = "A"
|
|
271
247
|
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
272
248
|
# may raise an exception
|
|
273
249
|
access_token: str = jwt.encode(payload=current_claims,
|
|
274
250
|
key=JWT_ENCODING_KEY,
|
|
275
|
-
algorithm=JWT_DEFAULT_ALGORITHM
|
|
251
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
252
|
+
headers=token_header)
|
|
253
|
+
|
|
254
|
+
# then issue the refresh token
|
|
255
|
+
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
256
|
+
current_claims["nat"] = "R"
|
|
257
|
+
# may raise an exception
|
|
258
|
+
refresh_token: str = jwt.encode(payload=current_claims,
|
|
259
|
+
key=JWT_ENCODING_KEY,
|
|
260
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
261
|
+
headers=token_header)
|
|
262
|
+
if JWT_DB_ENGINE:
|
|
263
|
+
# persist the refresh token
|
|
264
|
+
_jwt_persist_token(errors=errors,
|
|
265
|
+
account_id=account_id,
|
|
266
|
+
jwt_token=refresh_token,
|
|
267
|
+
logger=logger)
|
|
268
|
+
if errors:
|
|
269
|
+
raise RuntimeError("; ".join(errors))
|
|
270
|
+
|
|
276
271
|
# return the token data
|
|
277
272
|
result = {
|
|
278
273
|
"access_token": access_token,
|
|
@@ -301,7 +296,9 @@ def _jwt_request_token(errors: list[str],
|
|
|
301
296
|
Expected structure of the return data:
|
|
302
297
|
{
|
|
303
298
|
"access_token": <jwt-token>,
|
|
304
|
-
"
|
|
299
|
+
"created_in": <timestamp>,
|
|
300
|
+
"expires_in": <seconds-to-expiration>,
|
|
301
|
+
"refresh_token": <token>
|
|
305
302
|
}
|
|
306
303
|
It is up to the invoker to make sure that the *claims* data conform to the requirements
|
|
307
304
|
of the provider issuing the JWT token.
|
|
@@ -340,3 +337,68 @@ def _jwt_request_token(errors: list[str],
|
|
|
340
337
|
errors.append(err_msg)
|
|
341
338
|
|
|
342
339
|
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _jwt_persist_token(errors: list[str],
|
|
343
|
+
account_id: str,
|
|
344
|
+
jwt_token: str,
|
|
345
|
+
logger: Logger = None) -> None:
|
|
346
|
+
"""
|
|
347
|
+
Persist the given token, making sure that the account limit is adhered to.
|
|
348
|
+
|
|
349
|
+
:param errors: incidental errors
|
|
350
|
+
:param account_id: the account identification
|
|
351
|
+
:param jwt_token: the JWT token to persist
|
|
352
|
+
:param logger: optional logger
|
|
353
|
+
"""
|
|
354
|
+
from pypomes_db import db_select, db_insert, db_delete
|
|
355
|
+
from .jwt_pomes import jwt_get_claims
|
|
356
|
+
|
|
357
|
+
# retrieve the account's tokens
|
|
358
|
+
recs: list[tuple[str]] = db_select(errors=errors,
|
|
359
|
+
sel_stmt=f"SELECT {JWT_DB_COL_HASH}, {JWT_DB_COL_TOKEN} FROM {JWT_DB_TABLE} ",
|
|
360
|
+
where_data={JWT_DB_COL_ACCOUNT: account_id})
|
|
361
|
+
if not errors:
|
|
362
|
+
if logger:
|
|
363
|
+
logger.debug(msg=f"Read {len(recs)} token from storage for account '{account_id}'")
|
|
364
|
+
# remove the expired tokens
|
|
365
|
+
expired: list[str] = []
|
|
366
|
+
for rec in recs:
|
|
367
|
+
token: str = rec[1]
|
|
368
|
+
token_hash: str = rec[0]
|
|
369
|
+
token_claims: dict[str, Any] = jwt_get_claims(errors=errors,
|
|
370
|
+
token=token,
|
|
371
|
+
validate=False,
|
|
372
|
+
logger=logger)
|
|
373
|
+
if errors:
|
|
374
|
+
break
|
|
375
|
+
exp: int = token_claims["payload"]["exp"]
|
|
376
|
+
if exp < datetime.now(tz=timezone.utc).timestamp():
|
|
377
|
+
expired.append(token_hash)
|
|
378
|
+
|
|
379
|
+
if not errors:
|
|
380
|
+
# remove expired tokens from persistence
|
|
381
|
+
# ruff: noqa: SIM102
|
|
382
|
+
if expired:
|
|
383
|
+
if db_delete(errors=errors,
|
|
384
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
385
|
+
where_data={
|
|
386
|
+
JWT_DB_COL_ACCOUNT: account_id,
|
|
387
|
+
JWT_DB_COL_HASH: expired
|
|
388
|
+
},
|
|
389
|
+
logger=logger) is not None:
|
|
390
|
+
if logger:
|
|
391
|
+
logger.debug(msg=f"{len(expired)} tokens removed from storage")
|
|
392
|
+
if 0 < JWT_ACCOUNT_LIMIT <= len(recs) - len(expired):
|
|
393
|
+
errors.append("Maximum number of active sessions "
|
|
394
|
+
f"({JWT_ACCOUNT_LIMIT}) exceeded for account '{account_id}'")
|
|
395
|
+
# persist token
|
|
396
|
+
if not errors:
|
|
397
|
+
# ruff: noqa: S324
|
|
398
|
+
hasher = hashlib.new(name="md5",
|
|
399
|
+
data=jwt_token.encode())
|
|
400
|
+
token_hash: str = hasher.digest().decode()
|
|
401
|
+
db_insert(errors=errors,
|
|
402
|
+
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
403
|
+
insert_data={"ds_hash": token_hash,
|
|
404
|
+
"ds_token": jwt_token})
|
pypomes_jwt/jwt_pomes.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import jwt
|
|
2
3
|
from flask import Request, Response, request
|
|
3
4
|
from logging import Logger
|
|
@@ -31,6 +32,51 @@ def jwt_needed(func: callable) -> callable:
|
|
|
31
32
|
return wrapper
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def jwt_verify_request(request: Request,
|
|
36
|
+
logger: Logger = None) -> Response:
|
|
37
|
+
"""
|
|
38
|
+
Verify wheher the HTTP *request* has the proper authorization, as per the JWT standard.
|
|
39
|
+
|
|
40
|
+
:param request: the request to be verified
|
|
41
|
+
:param logger: optional logger
|
|
42
|
+
:return: *None* if the request is valid, otherwise a *Response* object reporting the error
|
|
43
|
+
"""
|
|
44
|
+
# initialize the return variable
|
|
45
|
+
result: Response | None = None
|
|
46
|
+
|
|
47
|
+
if logger:
|
|
48
|
+
logger.debug(msg="Validate a JWT token")
|
|
49
|
+
err_msg: str | None = None
|
|
50
|
+
|
|
51
|
+
# retrieve the authorization from the request header
|
|
52
|
+
auth_header: str = request.headers.get("Authorization")
|
|
53
|
+
|
|
54
|
+
# was a 'Bearer' authorization obtained ?
|
|
55
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
56
|
+
# yes, extract and validate the JWT access token
|
|
57
|
+
token: str = auth_header.split(" ")[1]
|
|
58
|
+
if logger:
|
|
59
|
+
logger.debug(msg=f"Token is '{token}'")
|
|
60
|
+
errors: list[str] = []
|
|
61
|
+
jwt_validate_token(errors=errors,
|
|
62
|
+
nature="A",
|
|
63
|
+
token=token)
|
|
64
|
+
if errors:
|
|
65
|
+
err_msg = "; ".join(errors)
|
|
66
|
+
else:
|
|
67
|
+
# no 'Bearer' found, report the error
|
|
68
|
+
err_msg = "Request header has no 'Bearer' data"
|
|
69
|
+
|
|
70
|
+
# log the error and deny the authorization
|
|
71
|
+
if err_msg:
|
|
72
|
+
if logger:
|
|
73
|
+
logger.error(msg=err_msg)
|
|
74
|
+
result = Response(response="Authorization failed",
|
|
75
|
+
status=401)
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
34
80
|
def jwt_assert_access(account_id: str) -> bool:
|
|
35
81
|
"""
|
|
36
82
|
Determine whether access for *account_id* has been established.
|
|
@@ -68,7 +114,7 @@ def jwt_set_access(account_id: str,
|
|
|
68
114
|
:param logger: optional logger
|
|
69
115
|
"""
|
|
70
116
|
if logger:
|
|
71
|
-
logger.debug(msg=f"Register
|
|
117
|
+
logger.debug(msg=f"Register account data for '{account_id}'")
|
|
72
118
|
|
|
73
119
|
# extract the claims provided in the reference URL's query string
|
|
74
120
|
pos: int = reference_url.find("?")
|
|
@@ -79,21 +125,21 @@ def jwt_set_access(account_id: str,
|
|
|
79
125
|
reference_url = reference_url[:pos]
|
|
80
126
|
|
|
81
127
|
# register the JWT service
|
|
82
|
-
__jwt_data.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def
|
|
96
|
-
|
|
128
|
+
__jwt_data.add_account(account_id=account_id,
|
|
129
|
+
reference_url=reference_url,
|
|
130
|
+
claims=claims,
|
|
131
|
+
access_max_age=access_max_age,
|
|
132
|
+
refresh_max_age=refresh_max_age,
|
|
133
|
+
grace_interval=grace_interval,
|
|
134
|
+
token_audience=token_audience,
|
|
135
|
+
token_nonce=token_nonce,
|
|
136
|
+
request_timeout=request_timeout,
|
|
137
|
+
remote_provider=remote_provider,
|
|
138
|
+
logger=logger)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def jwt_remove_account(account_id: str,
|
|
142
|
+
logger: Logger = None) -> bool:
|
|
97
143
|
"""
|
|
98
144
|
Remove from storage the JWT access data for *account_id*.
|
|
99
145
|
|
|
@@ -104,8 +150,8 @@ def jwt_remove_access(account_id: str,
|
|
|
104
150
|
if logger:
|
|
105
151
|
logger.debug(msg=f"Remove access data for '{account_id}'")
|
|
106
152
|
|
|
107
|
-
return __jwt_data.
|
|
108
|
-
|
|
153
|
+
return __jwt_data.remove_account(account_id=account_id,
|
|
154
|
+
logger=logger)
|
|
109
155
|
|
|
110
156
|
|
|
111
157
|
def jwt_validate_token(errors: list[str] | None,
|
|
@@ -136,7 +182,7 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
136
182
|
claims: dict[str, Any] = jwt.decode(jwt=token,
|
|
137
183
|
key=JWT_DECODING_KEY,
|
|
138
184
|
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
139
|
-
if nature and
|
|
185
|
+
if nature and nature != claims.get("nat"):
|
|
140
186
|
nat: str = "an access" if nature == "A" else "a refresh"
|
|
141
187
|
err_msg = f"Token is not {nat} token"
|
|
142
188
|
except Exception as e:
|
|
@@ -153,16 +199,18 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
153
199
|
return err_msg is None
|
|
154
200
|
|
|
155
201
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
|
|
202
|
+
def jwt_revoke_token(errors: list[str] | None,
|
|
203
|
+
account_id: str,
|
|
204
|
+
refresh_token: str,
|
|
205
|
+
logger: Logger = None) -> bool:
|
|
159
206
|
"""
|
|
160
|
-
Revoke
|
|
207
|
+
Revoke the *refresh_token* associated with *account_id*.
|
|
161
208
|
|
|
162
209
|
Revoke operations require access to a database table defined by *JWT_DB_TABLE*.
|
|
163
210
|
|
|
164
211
|
:param errors: incidental error messages
|
|
165
212
|
:param account_id: the account identification
|
|
213
|
+
:param refresh_token: the token to be revolked
|
|
166
214
|
:param logger: optional logger
|
|
167
215
|
:return: *True* if operation could be performed, *False* otherwise
|
|
168
216
|
"""
|
|
@@ -170,16 +218,25 @@ def jwt_revoke_tokens(errors: list[str] | None,
|
|
|
170
218
|
result: bool = False
|
|
171
219
|
|
|
172
220
|
if logger:
|
|
173
|
-
logger.debug(msg=f"Revoking refresh
|
|
221
|
+
logger.debug(msg=f"Revoking refresh token of '{account_id}'")
|
|
174
222
|
|
|
175
223
|
op_errors: list[str] = []
|
|
176
224
|
if JWT_DB_ENGINE:
|
|
177
|
-
from pypomes_db import db_delete
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
225
|
+
from pypomes_db import db_exists, db_delete
|
|
226
|
+
# ruff: noqa: S324
|
|
227
|
+
hasher = hashlib.new(name="md5",
|
|
228
|
+
data=refresh_token.encode())
|
|
229
|
+
token_hash: str = hasher.digest().decode()
|
|
230
|
+
if db_exists(errors=op_errors,
|
|
231
|
+
table=JWT_DB_TABLE,
|
|
232
|
+
where_data={"ds_hash": token_hash},
|
|
233
|
+
logger=logger):
|
|
234
|
+
db_delete(errors=errors,
|
|
235
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
236
|
+
where_data={"ds_hash": token_hash},
|
|
237
|
+
logger=logger)
|
|
238
|
+
elif not op_errors:
|
|
239
|
+
op_errors.append("Token was not found")
|
|
183
240
|
else:
|
|
184
241
|
op_errors.append("Database access for token revocation has not been specified")
|
|
185
242
|
|
|
@@ -233,7 +290,7 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
233
290
|
recs: list[tuple[str]] = db_select(errors=op_errors,
|
|
234
291
|
sel_stmt=f"SELECT {JWT_DB_COL_TOKEN} "
|
|
235
292
|
f"FROM {JWT_DB_TABLE}",
|
|
236
|
-
where_data={JWT_DB_COL_ACCOUNT:
|
|
293
|
+
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
237
294
|
logger=logger)
|
|
238
295
|
if not op_errors and \
|
|
239
296
|
(len(recs) == 0 or recs[0][0] != refresh_token):
|
|
@@ -247,7 +304,8 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
247
304
|
if not op_errors:
|
|
248
305
|
try:
|
|
249
306
|
result = __jwt_data.issue_tokens(account_id=account_id,
|
|
250
|
-
account_claims=account_claims
|
|
307
|
+
account_claims=account_claims,
|
|
308
|
+
logger=logger)
|
|
251
309
|
if logger:
|
|
252
310
|
logger.debug(msg=f"Data is '{result}'")
|
|
253
311
|
except Exception as e:
|
|
@@ -265,12 +323,44 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
265
323
|
|
|
266
324
|
def jwt_get_claims(errors: list[str] | None,
|
|
267
325
|
token: str,
|
|
326
|
+
validate: bool = True,
|
|
268
327
|
logger: Logger = None) -> dict[str, Any]:
|
|
269
328
|
"""
|
|
270
329
|
Obtain and return the claims set of a JWT *token*.
|
|
271
330
|
|
|
331
|
+
If *validate* is set to *True*, tha following pieces of information are verified:
|
|
332
|
+
- the token was issued and signed by the local provider, and is not corrupted
|
|
333
|
+
- the claim 'exp' is present and is in the future
|
|
334
|
+
- the claim 'nbf' is present and is in the past
|
|
335
|
+
|
|
336
|
+
Structure of the returned data:
|
|
337
|
+
{
|
|
338
|
+
"header": {
|
|
339
|
+
"alg": "HS256",
|
|
340
|
+
"typ": "JWT",
|
|
341
|
+
"kid": "rt466ytRTYH64577uydhDFGHDYJH2341"
|
|
342
|
+
},
|
|
343
|
+
"payload": {
|
|
344
|
+
"birthdate": "1980-01-01",
|
|
345
|
+
"email": "jdoe@mail.com",
|
|
346
|
+
"exp": 1516640454,
|
|
347
|
+
"iat": 1516239022,
|
|
348
|
+
"iss": "https://my_id_provider/issue",
|
|
349
|
+
"jti": "Uhsdfgr67FGH567qwSDF33er89retert",
|
|
350
|
+
"gender": "M,
|
|
351
|
+
"name": "John Doe",
|
|
352
|
+
"nbt": 1516249022
|
|
353
|
+
"sub": "1234567890",
|
|
354
|
+
"roles": [
|
|
355
|
+
"administrator",
|
|
356
|
+
"operator"
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
272
361
|
:param errors: incidental error messages
|
|
273
362
|
:param token: the token to be inspected for claims
|
|
363
|
+
:param validate: If *True*, verifies the token's data
|
|
274
364
|
:param logger: optional logger
|
|
275
365
|
:return: the token's claimset, or *None* if error
|
|
276
366
|
"""
|
|
@@ -281,14 +371,26 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
281
371
|
logger.debug(msg=f"Retrieve claims for token '{token}'")
|
|
282
372
|
|
|
283
373
|
try:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
374
|
+
# retrieve the token's payload
|
|
375
|
+
if validate:
|
|
376
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
377
|
+
options={
|
|
378
|
+
"verify_signature": True,
|
|
379
|
+
"verify_exp": True,
|
|
380
|
+
"verify_nbf": True
|
|
381
|
+
},
|
|
382
|
+
key=JWT_DECODING_KEY,
|
|
383
|
+
require=["exp", "nbf"],
|
|
384
|
+
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
290
385
|
else:
|
|
291
|
-
|
|
386
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
387
|
+
options={"verify_signature": False})
|
|
388
|
+
# retrieve the token's header
|
|
389
|
+
header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
390
|
+
result = {
|
|
391
|
+
"header": header,
|
|
392
|
+
"payload": payload
|
|
393
|
+
}
|
|
292
394
|
except Exception as e:
|
|
293
395
|
if logger:
|
|
294
396
|
logger.error(msg=str(e))
|
|
@@ -296,48 +398,3 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
296
398
|
errors.append(str(e))
|
|
297
399
|
|
|
298
400
|
return result
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def jwt_verify_request(request: Request,
|
|
302
|
-
logger: Logger = None) -> Response:
|
|
303
|
-
"""
|
|
304
|
-
Verify wheher the HTTP *request* has the proper authorization, as per the JWT standard.
|
|
305
|
-
|
|
306
|
-
:param request: the request to be verified
|
|
307
|
-
:param logger: optional logger
|
|
308
|
-
:return: *None* if the request is valid, otherwise a *Response* object reporting the error
|
|
309
|
-
"""
|
|
310
|
-
# initialize the return variable
|
|
311
|
-
result: Response | None = None
|
|
312
|
-
|
|
313
|
-
if logger:
|
|
314
|
-
logger.debug(msg="Validate a JWT token")
|
|
315
|
-
err_msg: str | None = None
|
|
316
|
-
|
|
317
|
-
# retrieve the authorization from the request header
|
|
318
|
-
auth_header: str = request.headers.get("Authorization")
|
|
319
|
-
|
|
320
|
-
# was a 'Bearer' authorization obtained ?
|
|
321
|
-
if auth_header and auth_header.startswith("Bearer "):
|
|
322
|
-
# yes, extract and validate the JWT access token
|
|
323
|
-
token: str = auth_header.split(" ")[1]
|
|
324
|
-
if logger:
|
|
325
|
-
logger.debug(msg=f"Token is '{token}'")
|
|
326
|
-
errors: list[str] = []
|
|
327
|
-
jwt_validate_token(errors=errors,
|
|
328
|
-
nature="A",
|
|
329
|
-
token=token)
|
|
330
|
-
if errors:
|
|
331
|
-
err_msg = "; ".join(errors)
|
|
332
|
-
else:
|
|
333
|
-
# no 'Bearer' found, report the error
|
|
334
|
-
err_msg = "Request header has no 'Bearer' data"
|
|
335
|
-
|
|
336
|
-
# log the error and deny the authorization
|
|
337
|
-
if err_msg:
|
|
338
|
-
if logger:
|
|
339
|
-
logger.error(msg=err_msg)
|
|
340
|
-
result = Response(response="Authorization failed",
|
|
341
|
-
status=401)
|
|
342
|
-
|
|
343
|
-
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.9
|
|
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
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pypomes_jwt/__init__.py,sha256=xUDd_xphRQFHuTxrvlQxO-mHIXgTqZjHWHMgp5gRrXU,1130
|
|
2
|
+
pypomes_jwt/jwt_constants.py,sha256=6-Jw4ORgf32hRWnaGyVISXMJMtTBk7LdKl3RrDy7Ll0,4328
|
|
3
|
+
pypomes_jwt/jwt_data.py,sha256=gyhGquSQbHevOKIoXmAmjMSwCjXB7pYbI2sY-7sGGO8,19158
|
|
4
|
+
pypomes_jwt/jwt_pomes.py,sha256=xNBlHhvrOH07WP6hKE0PyDl4fKSl0R1Xg7AQO_1b1uo,15201
|
|
5
|
+
pypomes_jwt-0.7.9.dist-info/METADATA,sha256=rXu25F1ufUL_U7frUaV1DMAj27pH8gvPX-TIA0qLlrg,599
|
|
6
|
+
pypomes_jwt-0.7.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
pypomes_jwt-0.7.9.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
+
pypomes_jwt-0.7.9.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
pypomes_jwt/__init__.py,sha256=dkWeYPNwypjwFuTjx4YtC8QV9ihykF4xcJJ7x86Wc5g,1130
|
|
2
|
-
pypomes_jwt/jwt_constants.py,sha256=xUX2raEaDUPJsjAm78lQ0APs4KSs5GYBxSPC5QKrrFE,4327
|
|
3
|
-
pypomes_jwt/jwt_data.py,sha256=YH3v8zvOURkB_o0XLMu2y2sFkKCxZBmbyWU5rC2gre4,16419
|
|
4
|
-
pypomes_jwt/jwt_pomes.py,sha256=eRQSLA8DVzr9MfVOh5bl6Zz3iOQtRIAQjzIfoz6Fj9o,12915
|
|
5
|
-
pypomes_jwt-0.7.6.dist-info/METADATA,sha256=5N0G1VHBXKHiH-QAnUBC9UgeqpjOh9-4-BYMtAl680U,599
|
|
6
|
-
pypomes_jwt-0.7.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
pypomes_jwt-0.7.6.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
-
pypomes_jwt-0.7.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|