pypomes-jwt 0.8.2__tar.gz → 0.8.3__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.8.2 → pypomes_jwt-0.8.3}/PKG-INFO +2 -1
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/pyproject.toml +3 -3
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/src/pypomes_jwt/__init__.py +8 -6
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/src/pypomes_jwt/jwt_constants.py +11 -20
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/src/pypomes_jwt/jwt_data.py +139 -86
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/src/pypomes_jwt/jwt_pomes.py +113 -92
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/.gitignore +0 -0
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/LICENSE +0 -0
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/README.md +0 -0
- {pypomes_jwt-0.8.2 → pypomes_jwt-0.8.3}/src/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
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
|
|
@@ -13,3 +13,4 @@ Requires-Python: >=3.12
|
|
|
13
13
|
Requires-Dist: cryptography>=44.0.2
|
|
14
14
|
Requires-Dist: pyjwt>=2.10.1
|
|
15
15
|
Requires-Dist: pypomes-core>=1.8.3
|
|
16
|
+
Requires-Dist: pypomes-db>=1.9.5
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pypomes_jwt"
|
|
9
|
-
version = "0.8.
|
|
9
|
+
version = "0.8.3"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name="GT Nunes", email="wisecoder01@gmail.com" }
|
|
12
12
|
]
|
|
@@ -21,8 +21,8 @@ classifiers = [
|
|
|
21
21
|
dependencies = [
|
|
22
22
|
"PyJWT>=2.10.1",
|
|
23
23
|
"cryptography>=44.0.2",
|
|
24
|
-
"pypomes_core>=1.8.3"
|
|
25
|
-
|
|
24
|
+
"pypomes_core>=1.8.3",
|
|
25
|
+
"pypomes_db>=1.9.5"
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[project.urls]
|
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
from .jwt_constants import (
|
|
2
2
|
JWT_DB_ENGINE, JWT_DB_HOST, JWT_DB_NAME,
|
|
3
3
|
JWT_DB_PORT, JWT_DB_USER, JWT_DB_PWD,
|
|
4
|
-
JWT_DB_TABLE,
|
|
4
|
+
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ACCOUNT,
|
|
5
|
+
JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER, JWT_DB_COL_TOKEN,
|
|
5
6
|
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
6
7
|
JWT_ENCODING_KEY, JWT_DECODING_KEY
|
|
7
8
|
)
|
|
8
9
|
from .jwt_pomes import (
|
|
9
10
|
jwt_needed, jwt_verify_request,
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
jwt_assert_account, jwt_set_account, jwt_remove_account,
|
|
12
|
+
jwt_get_tokens, jwt_get_claims, jwt_validate_token, jwt_revoke_token
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
15
16
|
# jwt_constants
|
|
16
17
|
"JWT_DB_ENGINE", "JWT_DB_HOST", "JWT_DB_NAME",
|
|
17
18
|
"JWT_DB_PORT", "JWT_DB_USER", "JWT_DB_PWD",
|
|
18
|
-
"JWT_DB_TABLE", "
|
|
19
|
+
"JWT_DB_TABLE", "JWT_DB_COL_KID", "JWT_DB_COL_ACCOUNT",
|
|
20
|
+
"JWT_DB_COL_ALGORITHM", "JWT_DB_COL_DECODER", "JWT_DB_COL_TOKEN",
|
|
19
21
|
"JWT_ACCESS_MAX_AGE", "JWT_REFRESH_MAX_AGE",
|
|
20
22
|
"JWT_ENCODING_KEY", "JWT_DECODING_KEY",
|
|
21
23
|
# jwt_pomes
|
|
22
24
|
"jwt_needed", "jwt_verify_request",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
+
"jwt_assert_account", "jwt_set_account", "jwt_remove_account",
|
|
26
|
+
"jwt_get_tokens", "jwt_get_claims", "jwt_validate_token", "jwt_revoke_token"
|
|
25
27
|
]
|
|
26
28
|
|
|
27
29
|
from importlib.metadata import version
|
|
@@ -5,7 +5,9 @@ from pypomes_core import (
|
|
|
5
5
|
APP_PREFIX,
|
|
6
6
|
env_get_str, env_get_bytes, env_get_int
|
|
7
7
|
)
|
|
8
|
+
from pypomes_db import DbEngine, db_setup
|
|
8
9
|
from secrets import token_bytes
|
|
10
|
+
from sys import stderr
|
|
9
11
|
from typing import Final
|
|
10
12
|
|
|
11
13
|
# database specs for token persistence
|
|
@@ -18,14 +20,14 @@ JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # fo
|
|
|
18
20
|
JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
|
|
19
21
|
JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
|
|
20
22
|
JWT_DB_COL_ACCOUNT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_ACCOUNT")
|
|
21
|
-
|
|
23
|
+
JWT_DB_COL_ALGORITHM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_ALGORITHM")
|
|
24
|
+
JWT_DB_COL_DECODER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_DECODER")
|
|
25
|
+
JWT_DB_COL_KID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_KID")
|
|
22
26
|
JWT_DB_COL_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_TOKEN")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
from sys import stderr
|
|
28
|
-
if db_setup(engine=DbEngine(__db_engine),
|
|
27
|
+
|
|
28
|
+
# define and validate the database engine
|
|
29
|
+
JWT_DB_ENGINE: Final[DbEngine] = DbEngine(env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE"))
|
|
30
|
+
if not db_setup(engine=JWT_DB_ENGINE,
|
|
29
31
|
db_name=JWT_DB_NAME,
|
|
30
32
|
db_user=JWT_DB_USER,
|
|
31
33
|
db_pwd=JWT_DB_PWD,
|
|
@@ -33,22 +35,11 @@ if __db_engine:
|
|
|
33
35
|
db_port=JWT_DB_PORT,
|
|
34
36
|
db_client=JWT_DB_CLIENT,
|
|
35
37
|
db_driver=JWT_DB_DRIVER):
|
|
36
|
-
|
|
37
|
-
if not db_assert_access(errors=__errors) or \
|
|
38
|
-
db_delete(errors=__errors,
|
|
39
|
-
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}") is None:
|
|
40
|
-
stderr.write(f"{'; '.join(__errors)}\n")
|
|
41
|
-
__db_engine = None
|
|
42
|
-
else:
|
|
43
|
-
stderr.write("Invalid database parameters\n")
|
|
44
|
-
__db_engine = None
|
|
45
|
-
# if set to 'None', no further attempt will be made to access the database
|
|
46
|
-
JWT_DB_ENGINE: Final[DbEngine] = DbEngine(__db_engine) if __db_engine else None
|
|
38
|
+
stderr.write("Invalid database parameters\n")
|
|
47
39
|
|
|
48
|
-
# one of HS256, HS512,
|
|
40
|
+
# one of HS256, HS512, RS256, RS512
|
|
49
41
|
JWT_DEFAULT_ALGORITHM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DEFAULT_ALGORITHM",
|
|
50
42
|
def_value="RS256")
|
|
51
|
-
|
|
52
43
|
# recommended: between 5 min and 1 hour (set to 5 min)
|
|
53
44
|
JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_AGE",
|
|
54
45
|
def_value=300)
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import hashlib
|
|
2
1
|
import jwt
|
|
3
2
|
import requests
|
|
4
3
|
import string
|
|
4
|
+
import sys
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from logging import Logger
|
|
7
7
|
from pypomes_core import str_random
|
|
8
|
+
from pypomes_db import db_connect, db_commit, db_update, db_delete
|
|
8
9
|
from requests import Response
|
|
9
10
|
from threading import Lock
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from .jwt_constants import (
|
|
13
|
-
JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY, JWT_DECODING_KEY,
|
|
14
|
-
|
|
14
|
+
JWT_DEFAULT_ALGORITHM, JWT_ACCOUNT_LIMIT, JWT_ENCODING_KEY, JWT_DECODING_KEY,
|
|
15
|
+
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ACCOUNT,
|
|
16
|
+
JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER, JWT_DB_COL_TOKEN
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
|
|
@@ -149,12 +151,10 @@ class JwtData:
|
|
|
149
151
|
with self.access_lock:
|
|
150
152
|
account_data = self.access_data.pop(account_id, None)
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
157
|
-
logger=logger)
|
|
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)
|
|
158
158
|
if logger:
|
|
159
159
|
if account_data:
|
|
160
160
|
logger.debug(f"Removed JWT data for '{account_id}'")
|
|
@@ -192,8 +192,7 @@ class JwtData:
|
|
|
192
192
|
:raises InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
193
193
|
:raises InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
194
194
|
:raises MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
195
|
-
:raises RuntimeError: error accessing the
|
|
196
|
-
the remote JWT provider failed to return a token
|
|
195
|
+
:raises RuntimeError: error accessing the token database
|
|
197
196
|
"""
|
|
198
197
|
# initialize the return variable
|
|
199
198
|
result: dict[str, Any] | None = None
|
|
@@ -209,8 +208,6 @@ class JwtData:
|
|
|
209
208
|
current_claims: dict[str, Any] = account_data.get("claims").copy()
|
|
210
209
|
if account_claims:
|
|
211
210
|
current_claims.update(account_claims)
|
|
212
|
-
|
|
213
|
-
# obtain new tokens
|
|
214
211
|
current_claims["jti"] = str_random(size=32,
|
|
215
212
|
chars=string.ascii_letters + string.digits)
|
|
216
213
|
current_claims["sub"] = account_id
|
|
@@ -233,41 +230,52 @@ class JwtData:
|
|
|
233
230
|
timeout=account_data.get("request-timeout"),
|
|
234
231
|
logger=logger)
|
|
235
232
|
if errors:
|
|
236
|
-
raise RuntimeError("
|
|
233
|
+
raise RuntimeError("; ".join(errors))
|
|
237
234
|
else:
|
|
238
235
|
# JWT service is being provided locally
|
|
239
236
|
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
240
237
|
current_claims["iat"] = just_now
|
|
241
|
-
token_header: dict[str, Any] = None \
|
|
242
|
-
if JWT_DEFAULT_ALGORITHM not in ["RS256", "RS512"] \
|
|
243
|
-
else {"kid": JWT_DECODING_KEY.hex()}
|
|
244
|
-
|
|
245
|
-
# issue the access token first
|
|
246
|
-
current_claims["nat"] = "A"
|
|
247
|
-
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
248
|
-
# may raise an exception
|
|
249
|
-
access_token: str = jwt.encode(payload=current_claims,
|
|
250
|
-
key=JWT_ENCODING_KEY,
|
|
251
|
-
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
252
|
-
headers=token_header)
|
|
253
238
|
|
|
254
|
-
#
|
|
239
|
+
# issue a candidate refresh token first, and persist it
|
|
255
240
|
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
256
|
-
current_claims["nat"] = "R"
|
|
257
241
|
# may raise an exception
|
|
258
242
|
refresh_token: str = jwt.encode(payload=current_claims,
|
|
259
243
|
key=JWT_ENCODING_KEY,
|
|
260
|
-
algorithm=JWT_DEFAULT_ALGORITHM
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
244
|
+
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
245
|
+
# obtain a DB connection (may raise an exception)
|
|
246
|
+
db_conn: Any = db_connect(errors=errors,
|
|
247
|
+
logger=logger)
|
|
248
|
+
# persist the candidate token (may raise an exception)
|
|
249
|
+
token_id: int = _jwt_persist_token(errors=errors,
|
|
250
|
+
account_id=account_id,
|
|
251
|
+
jwt_token=refresh_token,
|
|
252
|
+
db_conn=db_conn,
|
|
253
|
+
logger=logger)
|
|
254
|
+
# issue the definitive refresh token
|
|
255
|
+
refresh_token = jwt.encode(payload=current_claims,
|
|
256
|
+
key=JWT_ENCODING_KEY,
|
|
257
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
258
|
+
headers={"kid": str(token_id)})
|
|
259
|
+
# persist it
|
|
260
|
+
db_update(errors=errors,
|
|
261
|
+
update_stmt=f"UPDATE {JWT_DB_TABLE}",
|
|
262
|
+
update_data={JWT_DB_COL_TOKEN: refresh_token},
|
|
263
|
+
where_data={JWT_DB_COL_KID: token_id},
|
|
264
|
+
connection=db_conn,
|
|
265
|
+
logger=logger)
|
|
266
|
+
# commit the transaction
|
|
267
|
+
db_commit(errors=errors,
|
|
268
|
+
connection=db_conn,
|
|
269
|
+
logger=logger)
|
|
270
|
+
if errors:
|
|
271
|
+
raise RuntimeError("; ".join(errors))
|
|
270
272
|
|
|
273
|
+
# issue the access token
|
|
274
|
+
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
275
|
+
# may raise an exception
|
|
276
|
+
access_token: str = jwt.encode(payload=current_claims,
|
|
277
|
+
key=JWT_ENCODING_KEY,
|
|
278
|
+
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
271
279
|
# return the token data
|
|
272
280
|
result = {
|
|
273
281
|
"access_token": access_token,
|
|
@@ -342,64 +350,109 @@ def _jwt_request_token(errors: list[str],
|
|
|
342
350
|
def _jwt_persist_token(errors: list[str],
|
|
343
351
|
account_id: str,
|
|
344
352
|
jwt_token: str,
|
|
345
|
-
|
|
353
|
+
db_conn: Any = None,
|
|
354
|
+
logger: Logger = None) -> int:
|
|
346
355
|
"""
|
|
347
356
|
Persist the given token, making sure that the account limit is adhered to.
|
|
348
357
|
|
|
358
|
+
The tokens in storage, associated with *account_id*, are examined for their expiration timestamp.
|
|
359
|
+
If a token's expiration timestamp is in the past, it is removed from storage. If the maximum number
|
|
360
|
+
of active tokens for *account_id* has been reached, the oldest active one is alse removed,
|
|
361
|
+
to make room for the new *jwt_token*.
|
|
362
|
+
|
|
363
|
+
If *db_conn* is provided, then all DB operations will be carried out in the scope of a single transaction.
|
|
364
|
+
|
|
349
365
|
:param errors: incidental errors
|
|
350
366
|
:param account_id: the account identification
|
|
351
367
|
:param jwt_token: the JWT token to persist
|
|
368
|
+
:param db_conn: the database connection to use
|
|
352
369
|
:param logger: optional logger
|
|
370
|
+
:return: the storage id of the inserted token
|
|
371
|
+
:raises RuntimeError: error accessing the revocation database
|
|
353
372
|
"""
|
|
354
373
|
from pypomes_db import db_select, db_insert, db_delete
|
|
355
374
|
from .jwt_pomes import jwt_get_claims
|
|
356
375
|
|
|
357
376
|
# retrieve the account's tokens
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
377
|
+
# noinspection PyTypeChecker
|
|
378
|
+
recs: list[tuple[int, str, str, str]] = \
|
|
379
|
+
db_select(errors=errors,
|
|
380
|
+
sel_stmt=f"SELECT {JWT_DB_COL_KID}, {JWT_DB_COL_TOKEN} "
|
|
381
|
+
f"FROM {JWT_DB_TABLE}",
|
|
382
|
+
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
383
|
+
connection=db_conn)
|
|
384
|
+
if errors:
|
|
385
|
+
raise RuntimeError("; ".join(errors))
|
|
386
|
+
|
|
387
|
+
if logger:
|
|
388
|
+
logger.debug(msg=f"Read {len(recs)} token from storage for account '{account_id}'")
|
|
389
|
+
# remove the expired tokens
|
|
390
|
+
oldest: int = sys.maxsize
|
|
391
|
+
surplus: int | None = None
|
|
392
|
+
expired: list[int] = []
|
|
393
|
+
for rec in recs:
|
|
394
|
+
token: str = rec[1]
|
|
395
|
+
token_kid: int = rec[0]
|
|
396
|
+
token_claims: dict[str, Any] = jwt_get_claims(errors=errors,
|
|
397
|
+
token=token,
|
|
398
|
+
validate=False,
|
|
399
|
+
logger=logger)
|
|
400
|
+
if errors:
|
|
401
|
+
raise RuntimeError("; ".join(errors))
|
|
402
|
+
|
|
403
|
+
exp: int = token_claims["payload"].get("exp")
|
|
404
|
+
if exp < datetime.now(tz=timezone.utc).timestamp():
|
|
405
|
+
expired.append(token_kid)
|
|
406
|
+
elif exp < oldest:
|
|
407
|
+
oldest = exp
|
|
408
|
+
surplus = token_kid
|
|
409
|
+
|
|
410
|
+
# remove expired tokens from persistence
|
|
411
|
+
# ruff: noqa: SIM102
|
|
412
|
+
if expired:
|
|
413
|
+
db_delete(errors=errors,
|
|
414
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
415
|
+
where_data={JWT_DB_COL_KID: expired},
|
|
416
|
+
connection=db_conn,
|
|
417
|
+
logger=logger)
|
|
418
|
+
if errors:
|
|
419
|
+
raise RuntimeError("; ".join(errors))
|
|
420
|
+
if logger:
|
|
421
|
+
logger.debug(msg=f"{len(expired)} tokens of account "
|
|
422
|
+
f"'{account_id}' removed from storage")
|
|
423
|
+
|
|
424
|
+
if 0 < JWT_ACCOUNT_LIMIT <= len(recs) - len(expired):
|
|
425
|
+
# delete the oldest persisted token to make way for the new one
|
|
426
|
+
db_delete(errors=errors,
|
|
427
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
428
|
+
where_data={JWT_DB_COL_KID: surplus},
|
|
429
|
+
connection=db_conn,
|
|
430
|
+
logger=logger)
|
|
431
|
+
if errors:
|
|
432
|
+
raise RuntimeError("; ".join(errors))
|
|
362
433
|
if logger:
|
|
363
|
-
logger.debug(msg=
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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().hex()
|
|
401
|
-
db_insert(errors=errors,
|
|
402
|
-
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
403
|
-
insert_data={JWT_DB_COL_ACCOUNT: account_id,
|
|
404
|
-
JWT_DB_COL_HASH: token_hash,
|
|
405
|
-
JWT_DB_COL_TOKEN: jwt_token})
|
|
434
|
+
logger.debug(msg="Oldest active token of account "
|
|
435
|
+
f"'{account_id}' removed from storage")
|
|
436
|
+
# persist token
|
|
437
|
+
db_insert(errors=errors,
|
|
438
|
+
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
439
|
+
insert_data={JWT_DB_COL_ACCOUNT: account_id,
|
|
440
|
+
JWT_DB_COL_TOKEN: jwt_token,
|
|
441
|
+
JWT_DB_COL_ALGORITHM: JWT_DEFAULT_ALGORITHM,
|
|
442
|
+
JWT_DB_COL_DECODER: JWT_DECODING_KEY.hex()},
|
|
443
|
+
connection=db_conn,
|
|
444
|
+
logger=logger)
|
|
445
|
+
if errors:
|
|
446
|
+
raise RuntimeError("; ".join(errors))
|
|
447
|
+
|
|
448
|
+
# obtain the token's storage id
|
|
449
|
+
reply: list[tuple[int]] = db_select(errors=errors,
|
|
450
|
+
sel_stmt=f"SELECT {JWT_DB_COL_KID} "
|
|
451
|
+
f"FROM {JWT_DB_TABLE}",
|
|
452
|
+
where_data={JWT_DB_COL_TOKEN: jwt_token},
|
|
453
|
+
connection=db_conn,
|
|
454
|
+
logger=logger)
|
|
455
|
+
if errors:
|
|
456
|
+
raise RuntimeError("; ".join(errors))
|
|
457
|
+
|
|
458
|
+
return reply[0][0]
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import hashlib
|
|
2
1
|
import jwt
|
|
3
2
|
from flask import Request, Response, request
|
|
4
3
|
from logging import Logger
|
|
4
|
+
from pypomes_db import db_select, db_delete
|
|
5
5
|
from typing import Any, Literal
|
|
6
6
|
|
|
7
|
+
from . import JWT_DB_COL_ACCOUNT
|
|
7
8
|
from .jwt_constants import (
|
|
8
9
|
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
9
10
|
JWT_DEFAULT_ALGORITHM, JWT_DECODING_KEY,
|
|
10
|
-
|
|
11
|
+
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER
|
|
11
12
|
)
|
|
12
13
|
from .jwt_data import JwtData
|
|
13
14
|
|
|
@@ -73,7 +74,6 @@ def jwt_verify_request(request: Request,
|
|
|
73
74
|
logger.error(msg=err_msg)
|
|
74
75
|
result = Response(response="Authorization failed",
|
|
75
76
|
status=401)
|
|
76
|
-
|
|
77
77
|
return result
|
|
78
78
|
|
|
79
79
|
|
|
@@ -157,7 +157,8 @@ def jwt_remove_account(account_id: str,
|
|
|
157
157
|
def jwt_validate_token(errors: list[str] | None,
|
|
158
158
|
token: str,
|
|
159
159
|
nature: Literal["A", "R"] = None,
|
|
160
|
-
|
|
160
|
+
account_id: str = None,
|
|
161
|
+
logger: Logger = None) -> dict[str, Any] | None:
|
|
161
162
|
"""
|
|
162
163
|
Verify if *token* ia a valid JWT token.
|
|
163
164
|
|
|
@@ -166,48 +167,87 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
166
167
|
:param errors: incidental error messages
|
|
167
168
|
:param token: the token to be validated
|
|
168
169
|
:param nature: optionally validate the token's nature ("A": access token, "R": refresh token)
|
|
170
|
+
:param account_id: optionally, validate the token's account owner
|
|
169
171
|
:param logger: optional logger
|
|
170
|
-
:return:
|
|
172
|
+
:return: The token's claims (header and payload) if if is valid, *None* otherwise
|
|
171
173
|
"""
|
|
174
|
+
# initialize the return variable
|
|
175
|
+
result: dict[str, Any] | None = None
|
|
172
176
|
if logger:
|
|
173
177
|
logger.debug(msg=f"Validate JWT token '{token}'")
|
|
174
178
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
182
|
-
claims: dict[str, Any] = jwt.decode(jwt=token,
|
|
183
|
-
key=JWT_DECODING_KEY,
|
|
184
|
-
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
185
|
-
if nature and nature != claims.get("nat"):
|
|
186
|
-
nat: str = "an access" if nature == "A" else "a refresh"
|
|
187
|
-
err_msg = f"Token is not {nat} token"
|
|
188
|
-
elif JWT_DB_ENGINE and claims.get("nat") == "R":
|
|
189
|
-
from pypomes_db import db_exists
|
|
190
|
-
# ruff: noqa: S324
|
|
191
|
-
hasher = hashlib.new(name="md5",
|
|
192
|
-
data=token.encode())
|
|
193
|
-
token_hash: str = hasher.digest().hex()
|
|
194
|
-
if not db_exists(errors=errors,
|
|
195
|
-
table=JWT_DB_TABLE,
|
|
196
|
-
where_data={JWT_DB_COL_HASH: token_hash},
|
|
197
|
-
logger=logger):
|
|
198
|
-
err_msg = "Token is not valid"
|
|
199
|
-
except Exception as e:
|
|
200
|
-
err_msg = str(e)
|
|
179
|
+
# extract needed data from token header
|
|
180
|
+
token_header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
181
|
+
token_kid: int = int(token_header.get("kid") or 0)
|
|
182
|
+
token_alg: str | None = None
|
|
183
|
+
token_decoder: bytes | None = None
|
|
184
|
+
op_errors: list[str] = []
|
|
201
185
|
|
|
202
|
-
|
|
186
|
+
# retrieve token data from database
|
|
187
|
+
if (nature == "R" and not token_kid) or (nature == "A" and token_kid):
|
|
188
|
+
nat: str = "an access" if nature == "A" else "a refresh"
|
|
189
|
+
op_errors.append(f"Token is not {nat} token")
|
|
190
|
+
elif token_kid:
|
|
191
|
+
where_data: dict[str, str] = {JWT_DB_COL_KID: token_kid}
|
|
192
|
+
if account_id:
|
|
193
|
+
where_data[JWT_DB_COL_ACCOUNT] = account_id
|
|
194
|
+
recs: list[tuple[str]] = db_select(errors=op_errors,
|
|
195
|
+
sel_stmt=f"SELECT {JWT_DB_COL_ALGORITHM}, {JWT_DB_COL_DECODER} "
|
|
196
|
+
f"FROM {JWT_DB_TABLE}",
|
|
197
|
+
where_data=where_data,
|
|
198
|
+
logger=logger)
|
|
199
|
+
if recs:
|
|
200
|
+
token_alg = recs[0][0]
|
|
201
|
+
token_decoder = bytes.fromhex(recs[0][1])
|
|
202
|
+
else:
|
|
203
|
+
op_errors.append("Invalid token")
|
|
204
|
+
else:
|
|
205
|
+
token_alg = JWT_DEFAULT_ALGORITHM
|
|
206
|
+
token_decoder = JWT_DECODING_KEY
|
|
207
|
+
|
|
208
|
+
# validate the token
|
|
209
|
+
if not op_errors:
|
|
210
|
+
try:
|
|
211
|
+
# raises:
|
|
212
|
+
# InvalidTokenError: token is invalid
|
|
213
|
+
# InvalidKeyError: authentication key is not in the proper format
|
|
214
|
+
# ExpiredSignatureError: token and refresh period have expired
|
|
215
|
+
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
216
|
+
# ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
|
|
217
|
+
# InvalidAudienceError: 'aud' claim does not match one of the expected audience
|
|
218
|
+
# InvalidAlgorithmError: the specified algorithm is not recognized
|
|
219
|
+
# InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
220
|
+
# InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
221
|
+
# MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
222
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
223
|
+
options={
|
|
224
|
+
"verify_signature": True,
|
|
225
|
+
"verify_exp": True,
|
|
226
|
+
"verify_nbf": True
|
|
227
|
+
},
|
|
228
|
+
key=token_decoder,
|
|
229
|
+
require=["exp", "nbf"],
|
|
230
|
+
algorithms=token_alg)
|
|
231
|
+
if account_id and payload.get("sub") != account_id:
|
|
232
|
+
op_errors.append("Token does not belong to account")
|
|
233
|
+
else:
|
|
234
|
+
result = {
|
|
235
|
+
"header": token_header,
|
|
236
|
+
"payload": payload
|
|
237
|
+
}
|
|
238
|
+
except Exception as e:
|
|
239
|
+
op_errors.append(str(e))
|
|
240
|
+
|
|
241
|
+
if op_errors:
|
|
242
|
+
err_msg: str = "; ".join(op_errors)
|
|
203
243
|
if logger:
|
|
204
244
|
logger.error(msg=err_msg)
|
|
205
245
|
if isinstance(errors, list):
|
|
206
|
-
errors.
|
|
246
|
+
errors.extend(op_errors)
|
|
207
247
|
elif logger:
|
|
208
248
|
logger.debug(msg=f"Token '{token}' is valid")
|
|
209
249
|
|
|
210
|
-
return
|
|
250
|
+
return result
|
|
211
251
|
|
|
212
252
|
|
|
213
253
|
def jwt_revoke_token(errors: list[str] | None,
|
|
@@ -232,25 +272,20 @@ def jwt_revoke_token(errors: list[str] | None,
|
|
|
232
272
|
logger.debug(msg=f"Revoking refresh token of '{account_id}'")
|
|
233
273
|
|
|
234
274
|
op_errors: list[str] = []
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
elif not op_errors:
|
|
250
|
-
op_errors.append("Token was not found")
|
|
251
|
-
else:
|
|
252
|
-
op_errors.append("Database access for token revocation has not been specified")
|
|
253
|
-
|
|
275
|
+
token_claims: dict[str, Any] = jwt_validate_token(errors=op_errors,
|
|
276
|
+
token=refresh_token,
|
|
277
|
+
nature="R",
|
|
278
|
+
account_id=account_id,
|
|
279
|
+
logger=logger)
|
|
280
|
+
if not op_errors:
|
|
281
|
+
token_kid: int = int(token_claims["header"].get("kid") or 0)
|
|
282
|
+
db_delete(errors=op_errors,
|
|
283
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
284
|
+
where_data={
|
|
285
|
+
JWT_DB_COL_KID: token_kid,
|
|
286
|
+
JWT_DB_COL_ACCOUNT: account_id
|
|
287
|
+
},
|
|
288
|
+
logger=logger)
|
|
254
289
|
if op_errors:
|
|
255
290
|
if logger:
|
|
256
291
|
logger.error(msg="; ".join(op_errors))
|
|
@@ -283,7 +318,7 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
283
318
|
|
|
284
319
|
:param errors: incidental error messages
|
|
285
320
|
:param account_id: the account identification
|
|
286
|
-
:param account_claims: if provided, may supercede registered
|
|
321
|
+
:param account_claims: if provided, may supercede registered claims
|
|
287
322
|
:param refresh_token: if provided, defines a token refresh operation
|
|
288
323
|
:param logger: optional logger
|
|
289
324
|
:return: the JWT token data, or *None* if error
|
|
@@ -296,31 +331,24 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
296
331
|
op_errors: list[str] = []
|
|
297
332
|
if refresh_token:
|
|
298
333
|
# verify whether this refresh token is legitimate
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if not op_errors:
|
|
312
|
-
account_claims = jwt_get_claims(errors=op_errors,
|
|
313
|
-
token=refresh_token)
|
|
314
|
-
if not op_errors and (account_claims.get("payload") or {}).get("nat") != "R":
|
|
315
|
-
op_errors.append("Invalid parameters")
|
|
316
|
-
|
|
334
|
+
account_claims = (jwt_validate_token(errors=op_errors,
|
|
335
|
+
token=refresh_token,
|
|
336
|
+
nature="R",
|
|
337
|
+
account_id=account_id,
|
|
338
|
+
logger=logger) or {}).get("payload")
|
|
339
|
+
if account_claims:
|
|
340
|
+
account_claims.pop("iat", None)
|
|
341
|
+
account_claims.pop("jti", None)
|
|
342
|
+
account_claims.pop("iss", None)
|
|
343
|
+
account_claims.pop("exp", None)
|
|
344
|
+
account_claims.pop("nbt", None)
|
|
317
345
|
if not op_errors:
|
|
318
346
|
try:
|
|
319
347
|
result = __jwt_data.issue_tokens(account_id=account_id,
|
|
320
348
|
account_claims=account_claims,
|
|
321
349
|
logger=logger)
|
|
322
350
|
if logger:
|
|
323
|
-
logger.debug(msg=f"
|
|
351
|
+
logger.debug(msg=f"Token data is '{result}'")
|
|
324
352
|
except Exception as e:
|
|
325
353
|
# token issuing failed
|
|
326
354
|
op_errors.append(str(e))
|
|
@@ -336,8 +364,8 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
336
364
|
|
|
337
365
|
def jwt_get_claims(errors: list[str] | None,
|
|
338
366
|
token: str,
|
|
339
|
-
validate: bool =
|
|
340
|
-
logger: Logger = None) -> dict[str, Any]:
|
|
367
|
+
validate: bool = False,
|
|
368
|
+
logger: Logger = None) -> dict[str, Any] | None:
|
|
341
369
|
"""
|
|
342
370
|
Obtain and return the claims set of a JWT *token*.
|
|
343
371
|
|
|
@@ -373,7 +401,7 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
373
401
|
|
|
374
402
|
:param errors: incidental error messages
|
|
375
403
|
:param token: the token to be inspected for claims
|
|
376
|
-
:param validate: If *True*, verifies the token's data
|
|
404
|
+
:param validate: If *True*, verifies the token's data (defaults to *False*)
|
|
377
405
|
:param logger: optional logger
|
|
378
406
|
:return: the token's claimset, or *None* if error
|
|
379
407
|
"""
|
|
@@ -384,26 +412,19 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
384
412
|
logger.debug(msg=f"Retrieve claims for token '{token}'")
|
|
385
413
|
|
|
386
414
|
try:
|
|
387
|
-
# retrieve the token's
|
|
415
|
+
# retrieve the token's claims
|
|
388
416
|
if validate:
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
"verify_exp": True,
|
|
393
|
-
"verify_nbf": True
|
|
394
|
-
},
|
|
395
|
-
key=JWT_DECODING_KEY,
|
|
396
|
-
require=["exp", "nbf"],
|
|
397
|
-
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
417
|
+
result = jwt_validate_token(errors=errors,
|
|
418
|
+
token=token,
|
|
419
|
+
logger=logger)
|
|
398
420
|
else:
|
|
421
|
+
header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
399
422
|
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
400
423
|
options={"verify_signature": False})
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
"payload": payload
|
|
406
|
-
}
|
|
424
|
+
result = {
|
|
425
|
+
"header": header,
|
|
426
|
+
"payload": payload
|
|
427
|
+
}
|
|
407
428
|
except Exception as e:
|
|
408
429
|
if logger:
|
|
409
430
|
logger.error(msg=str(e))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|