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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_jwt
3
- Version: 0.7.3
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
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_jwt"
9
- version = "0.7.3"
9
+ version = "0.7.8"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -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
- def_value="jwt_token")
21
- JWT_DB_COL_ACCOUNT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_ACCOUNT",
22
- def_value="account_id")
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, JWT_ROTATE_TOKENS,
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,
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
- "reference_url": reference_url,
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
- 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)
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
- account_claims = jwt_get_claims(errors=op_errors,
231
- token=refresh_token)
232
- if not op_errors and account_claims.get("nat") != "R":
233
- op_errors.extend("Invalid parameters")
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
- claims: dict[str, Any] = jwt.decode(jwt=token,
273
- options={"verify_signature": False})
274
- if claims.get("nat") in ["A", "R"]:
275
- result = jwt.decode(jwt=token,
276
- key=JWT_DECODING_KEY,
277
- algorithms=[JWT_DEFAULT_ALGORITHM])
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
- result = claims
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