pypomes-iam 0.1.7__py3-none-any.whl → 0.1.8__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.
- pypomes_iam/common_pomes.py +111 -0
- pypomes_iam/jusbr_pomes.py +53 -118
- pypomes_iam/keycloak_pomes.py +167 -40
- {pypomes_iam-0.1.7.dist-info → pypomes_iam-0.1.8.dist-info}/METADATA +1 -1
- pypomes_iam-0.1.8.dist-info/RECORD +10 -0
- pypomes_iam-0.1.7.dist-info/RECORD +0 -9
- {pypomes_iam-0.1.7.dist-info → pypomes_iam-0.1.8.dist-info}/WHEEL +0 -0
- {pypomes_iam-0.1.7.dist-info → pypomes_iam-0.1.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import requests
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from flask import Request
|
|
5
|
+
from logging import Logger
|
|
6
|
+
from pypomes_core import TZ_LOCAL
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_public_key(registry: dict[str, Any],
|
|
11
|
+
url: str,
|
|
12
|
+
logger: Logger | None) -> bytes:
|
|
13
|
+
"""
|
|
14
|
+
Obtain the public key used by the *IAM* to sign the authentication tokens.
|
|
15
|
+
|
|
16
|
+
The public key is saved in *registry*.
|
|
17
|
+
|
|
18
|
+
:param url: the base URL to request the public key
|
|
19
|
+
:return: the public key, in *DER* format
|
|
20
|
+
"""
|
|
21
|
+
from pypomes_crypto import crypto_jwk_convert
|
|
22
|
+
|
|
23
|
+
# initialize the return variable
|
|
24
|
+
result: bytes | None = None
|
|
25
|
+
|
|
26
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
27
|
+
if now > registry.get("key-expiration"):
|
|
28
|
+
# obtain a new public key
|
|
29
|
+
url: str = f"{url}/protocol/openid-connect/certs"
|
|
30
|
+
if logger:
|
|
31
|
+
logger.debug(msg=f"GET '{url}'")
|
|
32
|
+
response: requests.Response = requests.get(url=url)
|
|
33
|
+
if response.status_code == 200:
|
|
34
|
+
# request succeeded
|
|
35
|
+
if logger:
|
|
36
|
+
logger.debug(msg=f"GET success, status {response.status_code}")
|
|
37
|
+
reply: dict[str, Any] = response.json()
|
|
38
|
+
result = crypto_jwk_convert(jwk=reply["keys"][0],
|
|
39
|
+
fmt="DER")
|
|
40
|
+
registry["public-key"] = result
|
|
41
|
+
duration: int = registry.get("key-lifetime") or 0
|
|
42
|
+
registry["key-expiration"] = now + duration
|
|
43
|
+
elif logger:
|
|
44
|
+
msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
|
|
45
|
+
if hasattr(response, "content") and response.content:
|
|
46
|
+
msg += f", content '{response.content}'"
|
|
47
|
+
logger.error(msg=msg)
|
|
48
|
+
else:
|
|
49
|
+
result = registry.get("public-key")
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_login_timeout(registry: dict[str, Any]) -> int | None:
|
|
55
|
+
"""
|
|
56
|
+
Retrieve from *registry* the timeout currently applicable for the login operation.
|
|
57
|
+
|
|
58
|
+
:return: the current login timeout, or *None* if none has been set.
|
|
59
|
+
"""
|
|
60
|
+
timeout: int = registry.get("client-timeout")
|
|
61
|
+
return timeout if isinstance(timeout, int) and timeout > 0 else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_user_data(registry: dict[str, Any],
|
|
65
|
+
user_id: str,
|
|
66
|
+
logger: Logger | None) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Retrieve the data for *user_id* from *registry*.
|
|
69
|
+
|
|
70
|
+
If an entry is not found for *user_id* in the registry, it is created.
|
|
71
|
+
It will remain there until the user is logged out.
|
|
72
|
+
|
|
73
|
+
:param user_id:
|
|
74
|
+
:return: the data for *user_id* in the registry
|
|
75
|
+
"""
|
|
76
|
+
result: dict[str, Any] = registry["users"].get(user_id)
|
|
77
|
+
if not result:
|
|
78
|
+
result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
|
|
79
|
+
registry["users"][user_id] = result
|
|
80
|
+
if logger:
|
|
81
|
+
logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
|
|
82
|
+
elif logger:
|
|
83
|
+
logger.debug(msg=f"Entry for user '{user_id}' obtained from the registry")
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _user_logout(registry: dict[str, Any],
|
|
89
|
+
user_id: str,
|
|
90
|
+
logger: Logger | None) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Remove all data associating *user_id* from *registry*.
|
|
93
|
+
"""
|
|
94
|
+
# remove the user data
|
|
95
|
+
if user_id and user_id in registry.get("users"):
|
|
96
|
+
registry["users"].pop(user_id)
|
|
97
|
+
if logger:
|
|
98
|
+
logger.debug(msg=f"User '{user_id}' removed from the registry")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _log_init(request: Request) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Build the messages for logging the request entry.
|
|
104
|
+
|
|
105
|
+
:param request: the Request object
|
|
106
|
+
:return: the log message
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
params: str = json.dumps(obj=request.args,
|
|
110
|
+
ensure_ascii=False)
|
|
111
|
+
return f"Request {request.method}:{request.path}, params {params}"
|
pypomes_iam/jusbr_pomes.py
CHANGED
|
@@ -5,13 +5,17 @@ import string
|
|
|
5
5
|
import sys
|
|
6
6
|
from cachetools import Cache, FIFOCache, TTLCache
|
|
7
7
|
from datetime import datetime
|
|
8
|
-
from flask import Flask,
|
|
8
|
+
from flask import Flask, Response, redirect, request, jsonify
|
|
9
9
|
from logging import Logger
|
|
10
10
|
from pypomes_core import (
|
|
11
11
|
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str, exc_format
|
|
12
12
|
)
|
|
13
13
|
from typing import Any, Final
|
|
14
14
|
|
|
15
|
+
from .common_pomes import (
|
|
16
|
+
_get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
|
|
17
|
+
)
|
|
18
|
+
|
|
15
19
|
JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
|
|
16
20
|
JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
|
|
17
21
|
JUSBR_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_CLIENT_TIMEOUT")
|
|
@@ -35,17 +39,19 @@ JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_A
|
|
|
35
39
|
# "client-id": <str>,
|
|
36
40
|
# "client-secret": <str>,
|
|
37
41
|
# "client-timeout": <int>,
|
|
38
|
-
# "public_key": <
|
|
42
|
+
# "public_key": <bytes>,
|
|
43
|
+
# "key-lifetime": <int>,
|
|
39
44
|
# "key-expiration": <int>,
|
|
40
|
-
# "
|
|
45
|
+
# "base-url": <str>,
|
|
41
46
|
# "callback-url": <str>,
|
|
42
47
|
# "users": {
|
|
43
48
|
# "<user-id>": {
|
|
44
49
|
# "cache-obj": <Cache>,
|
|
45
50
|
# "oauth-scope": <str>,
|
|
46
51
|
# "access-expiration": <timestamp>,
|
|
52
|
+
# "login-expiration": <int>, <-- transient
|
|
53
|
+
# "login-id": <str>, <-- transient
|
|
47
54
|
# data in <Cache>:
|
|
48
|
-
# "oauth-state": <str>
|
|
49
55
|
# "access-token": <str>
|
|
50
56
|
# "refresh-token": <str>
|
|
51
57
|
# }
|
|
@@ -66,7 +72,7 @@ def jusbr_setup(flask_app: Flask,
|
|
|
66
72
|
token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
|
|
67
73
|
login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
|
|
68
74
|
logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
|
|
69
|
-
|
|
75
|
+
base_url: str = JUSBR_URL_AUTH_BASE,
|
|
70
76
|
callback_url: str = JUSBR_URL_AUTH_CALLBACK,
|
|
71
77
|
logger: Logger = None) -> None:
|
|
72
78
|
"""
|
|
@@ -83,7 +89,7 @@ def jusbr_setup(flask_app: Flask,
|
|
|
83
89
|
:param token_endpoint: endpoint for retrieving the JusBR authentication token
|
|
84
90
|
:param login_endpoint: endpoint for redirecting user to JusBR login page
|
|
85
91
|
:param logout_endpoint: endpoint for terminating user access to JusBR
|
|
86
|
-
:param
|
|
92
|
+
:param base_url: base URL to request the JusBR services
|
|
87
93
|
:param callback_url: URL for JusBR to callback on login
|
|
88
94
|
:param logger: optional logger
|
|
89
95
|
"""
|
|
@@ -97,7 +103,7 @@ def jusbr_setup(flask_app: Flask,
|
|
|
97
103
|
"client-id": client_id,
|
|
98
104
|
"client-secret": client_secret,
|
|
99
105
|
"client-timeout": client_timeout,
|
|
100
|
-
"
|
|
106
|
+
"base-url": base_url,
|
|
101
107
|
"callback-url": callback_url,
|
|
102
108
|
"key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
|
|
103
109
|
"key-lifetime": public_key_lifetime,
|
|
@@ -141,27 +147,28 @@ def service_login() -> Response:
|
|
|
141
147
|
|
|
142
148
|
# log the request
|
|
143
149
|
if _logger:
|
|
144
|
-
msg: str =
|
|
150
|
+
msg: str = _log_init(request=request)
|
|
145
151
|
_logger.debug(msg=msg)
|
|
146
152
|
|
|
147
|
-
# retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
|
|
153
|
+
# retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state')
|
|
148
154
|
input_params: dict[str, Any] = request.values
|
|
149
155
|
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
150
156
|
user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
|
|
151
|
-
# obtain user data
|
|
152
|
-
user_data: dict[str, Any] =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
# obtain the user data
|
|
158
|
+
user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
|
|
159
|
+
user_id=user_id,
|
|
160
|
+
logger=_logger)
|
|
161
|
+
# build the redirect url
|
|
162
|
+
timeout: int = _get_login_timeout(registry=_jusbr_registry)
|
|
156
163
|
safe_cache: Cache
|
|
157
164
|
if timeout:
|
|
158
165
|
safe_cache = TTLCache(maxsize=16,
|
|
159
|
-
ttl=
|
|
166
|
+
ttl=timeout)
|
|
160
167
|
else:
|
|
161
168
|
safe_cache = FIFOCache(maxsize=16)
|
|
162
169
|
safe_cache["oauth-state"] = oauth_state
|
|
163
170
|
user_data["cache-obj"] = safe_cache
|
|
164
|
-
auth_url: str = (f"{_jusbr_registry["
|
|
171
|
+
auth_url: str = (f"{_jusbr_registry["base-url"]}/protocol/openid-connect/auth?response_type=code"
|
|
165
172
|
f"&client_id={_jusbr_registry["client-id"]}"
|
|
166
173
|
f"&redirect_uri={_jusbr_registry["callback-url"]}"
|
|
167
174
|
f"&state={oauth_state}")
|
|
@@ -192,18 +199,17 @@ def service_logout() -> Response:
|
|
|
192
199
|
|
|
193
200
|
# log the request
|
|
194
201
|
if _logger:
|
|
195
|
-
msg: str =
|
|
202
|
+
msg: str = _log_init(request=request)
|
|
196
203
|
_logger.debug(msg=msg)
|
|
197
204
|
|
|
198
|
-
# retrieve user id
|
|
205
|
+
# retrieve the user id
|
|
199
206
|
input_params: dict[str, Any] = request.args
|
|
200
207
|
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
201
208
|
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
_logger.debug(f"User '{user_id}' removed from the registry")
|
|
209
|
+
# logout the user
|
|
210
|
+
_user_logout(registry=_jusbr_registry,
|
|
211
|
+
user_id=user_id,
|
|
212
|
+
logger=_logger)
|
|
207
213
|
|
|
208
214
|
result: Response = Response(status=200)
|
|
209
215
|
|
|
@@ -227,7 +233,7 @@ def service_callback() -> Response:
|
|
|
227
233
|
|
|
228
234
|
# log the request
|
|
229
235
|
if _logger:
|
|
230
|
-
msg: str =
|
|
236
|
+
msg: str = _log_init(request=request)
|
|
231
237
|
_logger.debug(msg=msg)
|
|
232
238
|
|
|
233
239
|
# validate the OAuth2 state
|
|
@@ -253,7 +259,7 @@ def service_callback() -> Response:
|
|
|
253
259
|
body_data: dict[str, Any] = {
|
|
254
260
|
"grant_type": "authorization_code",
|
|
255
261
|
"code": code,
|
|
256
|
-
"
|
|
262
|
+
"redirect_uri": _jusbr_registry.get("callback-url"),
|
|
257
263
|
}
|
|
258
264
|
token = __post_jusbr(user_data=user_data,
|
|
259
265
|
body_data=body_data,
|
|
@@ -261,9 +267,12 @@ def service_callback() -> Response:
|
|
|
261
267
|
logger=_logger)
|
|
262
268
|
# retrieve the token's claims
|
|
263
269
|
if not errors:
|
|
270
|
+
public_key: bytes = _get_public_key(registry=_jusbr_registry,
|
|
271
|
+
url=_jusbr_registry["base-url"],
|
|
272
|
+
logger=_logger)
|
|
264
273
|
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
265
|
-
issuer=_jusbr_registry
|
|
266
|
-
public_key=
|
|
274
|
+
issuer=_jusbr_registry["base-url"],
|
|
275
|
+
public_key=public_key,
|
|
267
276
|
errors=errors,
|
|
268
277
|
logger=_logger)
|
|
269
278
|
if not errors:
|
|
@@ -275,7 +284,7 @@ def service_callback() -> Response:
|
|
|
275
284
|
errors.append(f"Token was issued to user '{token_user}'")
|
|
276
285
|
else:
|
|
277
286
|
msg: str = "Unknown OAuth2 code received"
|
|
278
|
-
if
|
|
287
|
+
if _get_login_timeout(registry=_jusbr_registry):
|
|
279
288
|
msg += " - possible operation timeout"
|
|
280
289
|
errors.append(msg)
|
|
281
290
|
|
|
@@ -305,7 +314,7 @@ def service_token() -> Response:
|
|
|
305
314
|
"""
|
|
306
315
|
# log the request
|
|
307
316
|
if _logger:
|
|
308
|
-
msg: str =
|
|
317
|
+
msg: str = _log_init(request=request)
|
|
309
318
|
_logger.debug(msg=msg)
|
|
310
319
|
|
|
311
320
|
# retrieve the token
|
|
@@ -344,8 +353,9 @@ def jusbr_get_token(user_id: str,
|
|
|
344
353
|
# initialize the return variable
|
|
345
354
|
result: str | None = None
|
|
346
355
|
|
|
347
|
-
user_data: dict[str, Any] =
|
|
348
|
-
|
|
356
|
+
user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
|
|
357
|
+
user_id=user_id,
|
|
358
|
+
logger=logger)
|
|
349
359
|
safe_cache: Cache = user_data.get("cache-obj")
|
|
350
360
|
if safe_cache:
|
|
351
361
|
access_expiration: int = user_data.get("access-expiration")
|
|
@@ -389,83 +399,13 @@ def jusbr_set_scope(user_id: str,
|
|
|
389
399
|
global _jusbr_registry
|
|
390
400
|
|
|
391
401
|
# retrieve user data
|
|
392
|
-
user_data: dict[str, Any] =
|
|
393
|
-
|
|
402
|
+
user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
|
|
403
|
+
user_id=user_id,
|
|
404
|
+
logger=logger)
|
|
394
405
|
# set the OAuth2 scope
|
|
395
406
|
user_data["oauth-scope"] = scope
|
|
396
407
|
if logger:
|
|
397
|
-
logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def __get_public_key(url: str,
|
|
401
|
-
logger: Logger | None) -> str:
|
|
402
|
-
"""
|
|
403
|
-
Obtain the public key used by JusBR to sign the authentication tokens.
|
|
404
|
-
|
|
405
|
-
:param url: the base URL to request the public key
|
|
406
|
-
:return: the public key, in *PEM* format
|
|
407
|
-
"""
|
|
408
|
-
from pypomes_crypto import crypto_jwk_convert
|
|
409
|
-
global _jusbr_registry
|
|
410
|
-
|
|
411
|
-
# initialize the return variable
|
|
412
|
-
result: str | None = None
|
|
413
|
-
|
|
414
|
-
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
415
|
-
if now > _jusbr_registry.get("key-expiration"):
|
|
416
|
-
# obtain a new public key
|
|
417
|
-
url: str = f"{url}/protocol/openid-connect/certs"
|
|
418
|
-
response: requests.Response = requests.get(url=url)
|
|
419
|
-
if response.status_code == 200:
|
|
420
|
-
# request succeeded
|
|
421
|
-
reply: dict[str, Any] = response.json()
|
|
422
|
-
result = crypto_jwk_convert(jwk=reply["keys"][0],
|
|
423
|
-
fmt="PEM")
|
|
424
|
-
_jusbr_registry["public-key"] = result
|
|
425
|
-
duration: int = _jusbr_registry.get("key-lifetime") or 0
|
|
426
|
-
_jusbr_registry["key-expiration"] = now + duration
|
|
427
|
-
elif logger:
|
|
428
|
-
logger.error(msg=f"GET '{url}': failed, "
|
|
429
|
-
f"status {response.status_code}, reason '{response.reason}'")
|
|
430
|
-
else:
|
|
431
|
-
result = _jusbr_registry.get("public-key")
|
|
432
|
-
|
|
433
|
-
return result
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def __get_login_timeout() -> int | None:
|
|
437
|
-
"""
|
|
438
|
-
Retrieve the timeout currently applicable for the login operation.
|
|
439
|
-
|
|
440
|
-
:return: the current login timeout, or *None* if none has been set.
|
|
441
|
-
"""
|
|
442
|
-
global _jusbr_registry
|
|
443
|
-
|
|
444
|
-
timeout: int = _jusbr_registry.get("client-timeout")
|
|
445
|
-
return timeout if isinstance(timeout, int) and timeout > 0 else None
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
def __get_user_data(user_id: str,
|
|
449
|
-
logger: Logger | None) -> dict[str, Any]:
|
|
450
|
-
"""
|
|
451
|
-
Retrieve the data for *user_id* from the registry.
|
|
452
|
-
|
|
453
|
-
If an entry is not found for *user_id* in the registry, it is created.
|
|
454
|
-
It will remain there until the user is logged out.
|
|
455
|
-
|
|
456
|
-
:param user_id:
|
|
457
|
-
:return: the data for *user_id* in the registry
|
|
458
|
-
"""
|
|
459
|
-
global _jusbr_registry
|
|
460
|
-
|
|
461
|
-
result: dict[str, Any] = _jusbr_registry["users"].get(user_id)
|
|
462
|
-
if not result:
|
|
463
|
-
result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
|
|
464
|
-
_jusbr_registry["users"][user_id] = result
|
|
465
|
-
if logger:
|
|
466
|
-
logger.debug(f"Entry for user '{user_id}' added to registry")
|
|
467
|
-
|
|
468
|
-
return result
|
|
408
|
+
logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
|
|
469
409
|
|
|
470
410
|
|
|
471
411
|
def __post_jusbr(user_data: dict[str, Any],
|
|
@@ -475,7 +415,7 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
475
415
|
"""
|
|
476
416
|
Send a POST request to JusBR to obtain the authentication token data, and return the access token.
|
|
477
417
|
|
|
478
|
-
For
|
|
418
|
+
For token exchange, *body_data* will have the attributes
|
|
479
419
|
- "grant_type": "authorization_code"
|
|
480
420
|
- "code": <16-character-random-code>
|
|
481
421
|
- "redirect_uri": <callback-url>
|
|
@@ -506,8 +446,11 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
506
446
|
# obtain the token
|
|
507
447
|
err_msg: str | None = None
|
|
508
448
|
safe_cache: Cache = user_data.get("cache-obj")
|
|
509
|
-
url: str = _jusbr_registry.get("
|
|
449
|
+
url: str = _jusbr_registry.get("base-url") + "/protocol/openid-connect/token"
|
|
510
450
|
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
451
|
+
if logger:
|
|
452
|
+
logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
|
|
453
|
+
ensure_ascii=False)}")
|
|
511
454
|
try:
|
|
512
455
|
# JusBR return on a token request:
|
|
513
456
|
# {
|
|
@@ -520,6 +463,8 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
520
463
|
data=body_data)
|
|
521
464
|
if response.status_code == 200:
|
|
522
465
|
# request succeeded
|
|
466
|
+
if logger:
|
|
467
|
+
logger.debug(msg=f"POST success, status {response.status_code}")
|
|
523
468
|
reply: dict[str, Any] = response.json()
|
|
524
469
|
result = reply.get("access_token")
|
|
525
470
|
safe_cache: Cache = FIFOCache(maxsize=1024)
|
|
@@ -528,12 +473,9 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
528
473
|
safe_cache["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
|
|
529
474
|
user_data["cache-obj"] = safe_cache
|
|
530
475
|
user_data["access-expiration"] = now + reply.get("expires_in")
|
|
531
|
-
if logger:
|
|
532
|
-
logger.debug(msg=f"POST '{url}': status {response.status_code}")
|
|
533
476
|
else:
|
|
534
477
|
# request resulted in error
|
|
535
|
-
err_msg =
|
|
536
|
-
f"status {response.status_code}, reason '{response.reason}'")
|
|
478
|
+
err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
|
|
537
479
|
if hasattr(response, "content") and response.content:
|
|
538
480
|
err_msg += f", content '{response.content}'"
|
|
539
481
|
if response.status_code == 401 and "refresh_token" in body_data:
|
|
@@ -552,10 +494,3 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
552
494
|
logger.error(msg=err_msg)
|
|
553
495
|
|
|
554
496
|
return result
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def __log_init(request: Request) -> str:
|
|
558
|
-
|
|
559
|
-
params: str = json.dumps(obj=request.args,
|
|
560
|
-
ensure_ascii=False)
|
|
561
|
-
return f"Request {request.method}:{request.path}, params {params}"
|
pypomes_iam/keycloak_pomes.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
# import string
|
|
1
|
+
import secrets
|
|
2
|
+
import string
|
|
4
3
|
# import sys
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
from cachetools import FIFOCache
|
|
5
|
+
from datetime import datetime
|
|
7
6
|
from flask import Flask, Response, redirect, request, jsonify
|
|
8
7
|
from logging import Logger
|
|
9
8
|
from pypomes_core import (
|
|
10
|
-
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
|
|
9
|
+
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
|
|
11
10
|
)
|
|
12
11
|
from typing import Any, Final
|
|
13
12
|
|
|
13
|
+
from .common_pomes import (
|
|
14
|
+
_get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
|
|
15
|
+
)
|
|
16
|
+
|
|
14
17
|
KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
|
|
15
18
|
KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
|
|
16
19
|
KEYCLOAK_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_TIMEOUT")
|
|
@@ -24,6 +27,8 @@ KEYCLOAK_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_E
|
|
|
24
27
|
KEYCLOAK_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_TOKEN",
|
|
25
28
|
def_value="/iam/keycloak:get-token")
|
|
26
29
|
|
|
30
|
+
KEYCLOAK_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_PUBLIC_KEY_LIFETIME",
|
|
31
|
+
def_value=86400) # 24 hours
|
|
27
32
|
KEYCLOAK_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_REALM")
|
|
28
33
|
KEYCLOAK_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_BASE")
|
|
29
34
|
KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_CALLBACK")
|
|
@@ -33,30 +38,24 @@ KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK
|
|
|
33
38
|
# "client-id": <str>,
|
|
34
39
|
# "client-secret": <str>,
|
|
35
40
|
# "client-timeout": <int>,
|
|
36
|
-
# "
|
|
37
|
-
# "
|
|
41
|
+
# "public_key": <str>,
|
|
42
|
+
# "key-lifetime": <int>,
|
|
43
|
+
# "key-expiration": <int>,
|
|
44
|
+
# "base-url": <str>,
|
|
38
45
|
# "callback-url": <str>,
|
|
39
46
|
# "users": {
|
|
40
47
|
# "<user-id>": {
|
|
41
|
-
# "cache-obj": <
|
|
42
|
-
# "oauth-scope": <str>,
|
|
48
|
+
# "cache-obj": <FIFOCache>,
|
|
43
49
|
# "access-expiration": <timestamp>,
|
|
44
|
-
#
|
|
45
|
-
#
|
|
50
|
+
# "login-expiration": <int>, <-- transient
|
|
51
|
+
# "login-id": <str>, <-- transient
|
|
52
|
+
# data in <FIFOCache>:
|
|
46
53
|
# "access-token": <str>
|
|
47
54
|
# "refresh-token": <str>
|
|
48
55
|
# }
|
|
49
56
|
# }
|
|
50
57
|
# }
|
|
51
|
-
_keycloak_registry: dict[str, Any] = {
|
|
52
|
-
"client-id": None,
|
|
53
|
-
"client-secret": None,
|
|
54
|
-
"client-timeout": None,
|
|
55
|
-
"realm": None,
|
|
56
|
-
"auth-url": None,
|
|
57
|
-
"callback-url": None,
|
|
58
|
-
"users": {}
|
|
59
|
-
}
|
|
58
|
+
_keycloak_registry: dict[str, Any] = {}
|
|
60
59
|
|
|
61
60
|
# dafault logger
|
|
62
61
|
_logger: Logger | None = None
|
|
@@ -66,12 +65,13 @@ def keycloak_setup(flask_app: Flask,
|
|
|
66
65
|
client_id: str = KEYCLOAK_CLIENT_ID,
|
|
67
66
|
client_secret: str = KEYCLOAK_CLIENT_SECRET,
|
|
68
67
|
client_timeout: int = KEYCLOAK_CLIENT_TIMEOUT,
|
|
68
|
+
public_key_lifetime: int = KEYCLOAK_PUBLIC_KEY_LIFETIME,
|
|
69
69
|
realm: str = KEYCLOAK_REALM,
|
|
70
70
|
callback_endpoint: str = KEYCLOAK_ENDPOINT_CALLBACK,
|
|
71
71
|
token_endpoint: str = KEYCLOAK_ENDPOINT_TOKEN,
|
|
72
72
|
login_endpoint: str = KEYCLOAK_ENDPOINT_LOGIN,
|
|
73
73
|
logout_endpoint: str = KEYCLOAK_ENDPOINT_LOGOUT,
|
|
74
|
-
|
|
74
|
+
base_url: str = KEYCLOAK_URL_AUTH_BASE,
|
|
75
75
|
callback_url: str = KEYCLOAK_URL_AUTH_CALLBACK,
|
|
76
76
|
logger: Logger = None) -> None:
|
|
77
77
|
"""
|
|
@@ -83,12 +83,13 @@ def keycloak_setup(flask_app: Flask,
|
|
|
83
83
|
:param client_id: the client's identification with JusBR
|
|
84
84
|
:param client_secret: the client's password with JusBR
|
|
85
85
|
:param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
|
|
86
|
-
:param
|
|
86
|
+
:param public_key_lifetime: how long to use Keycloak's public key, before refreshing it (in seconds)
|
|
87
|
+
:param realm: the Keycloak realm
|
|
87
88
|
:param callback_endpoint: endpoint for the callback from JusBR
|
|
88
89
|
:param token_endpoint: endpoint for retrieving the JusBR authentication token
|
|
89
90
|
:param login_endpoint: endpoint for redirecting user to JusBR login page
|
|
90
91
|
:param logout_endpoint: endpoint for terminating user access to JusBR
|
|
91
|
-
:param
|
|
92
|
+
:param base_url: base URL to request the JusBR services
|
|
92
93
|
:param callback_url: URL for Keycloak to callback on login
|
|
93
94
|
:param logger: optional logger
|
|
94
95
|
"""
|
|
@@ -99,15 +100,16 @@ def keycloak_setup(flask_app: Flask,
|
|
|
99
100
|
_logger = logger
|
|
100
101
|
|
|
101
102
|
# configure the JusBR registry
|
|
102
|
-
_keycloak_registry
|
|
103
|
+
_keycloak_registry = {
|
|
103
104
|
"client-id": client_id,
|
|
104
105
|
"client-secret": client_secret,
|
|
105
106
|
"client-timeout": client_timeout,
|
|
106
|
-
"
|
|
107
|
-
"auth-url": auth_url,
|
|
107
|
+
"base-url": f"{base_url}/realms/{realm}",
|
|
108
108
|
"callback-url": callback_url,
|
|
109
|
+
"key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
|
|
110
|
+
"key-lifetime": public_key_lifetime,
|
|
109
111
|
"users": []
|
|
110
|
-
}
|
|
112
|
+
}
|
|
111
113
|
|
|
112
114
|
# establish the endpoints
|
|
113
115
|
if token_endpoint:
|
|
@@ -144,28 +146,77 @@ def service_login() -> Response:
|
|
|
144
146
|
"""
|
|
145
147
|
global _keycloak_registry
|
|
146
148
|
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
# log the request
|
|
150
|
+
if _logger:
|
|
151
|
+
msg: str = _log_init(request=request)
|
|
152
|
+
_logger.debug(msg=msg)
|
|
153
|
+
|
|
154
|
+
# build the OAuth2 state, and temporarily use it as 'user_id'
|
|
155
|
+
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
156
|
+
# obtain the user data
|
|
157
|
+
user_data: dict[str, Any] = _get_user_data(registry=_keycloak_registry,
|
|
158
|
+
user_id=oauth_state,
|
|
159
|
+
logger=_logger)
|
|
160
|
+
# build the redirect url
|
|
161
|
+
timeout: int = _get_login_timeout(registry=_keycloak_registry)
|
|
162
|
+
safe_cache: FIFOCache
|
|
163
|
+
if timeout:
|
|
164
|
+
safe_cache = FIFOCache(maxsize=16)
|
|
165
|
+
else:
|
|
166
|
+
safe_cache = FIFOCache(maxsize=16)
|
|
167
|
+
safe_cache["valid"] = True
|
|
168
|
+
user_data["cache-obj"] = safe_cache
|
|
169
|
+
auth_url: str = (
|
|
170
|
+
f"{_keycloak_registry["base-url"]}/protocol/openid-connect/auth"
|
|
171
|
+
f"?client_id={_keycloak_registry["client-id"]}"
|
|
172
|
+
f"&response_type=code"
|
|
173
|
+
f"&scope=openid"
|
|
174
|
+
f"&redirect_uri={_keycloak_registry["callback-url"]}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# redirect the request
|
|
178
|
+
result: Response = redirect(location=auth_url)
|
|
179
|
+
|
|
180
|
+
# log the response
|
|
181
|
+
if _logger:
|
|
182
|
+
_logger.debug(msg=f"Response {result}")
|
|
183
|
+
|
|
184
|
+
return result
|
|
151
185
|
|
|
152
186
|
|
|
153
187
|
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
|
|
154
188
|
# methods=["GET"])
|
|
155
189
|
def service_logout() -> Response:
|
|
156
190
|
"""
|
|
157
|
-
Entry point for the
|
|
191
|
+
Entry point for the Keycloak logout service.
|
|
158
192
|
|
|
159
|
-
Remove all data associating the user with
|
|
193
|
+
Remove all data associating the user with Keycloak from the registry.
|
|
160
194
|
|
|
161
195
|
:return: response *OK*
|
|
162
196
|
"""
|
|
163
197
|
global _keycloak_registry
|
|
164
198
|
|
|
165
|
-
#
|
|
199
|
+
# log the request
|
|
200
|
+
if _logger:
|
|
201
|
+
msg: str = _log_init(request=request)
|
|
202
|
+
_logger.debug(msg=msg)
|
|
203
|
+
|
|
204
|
+
# retrieve the user id
|
|
166
205
|
input_params: dict[str, Any] = request.args
|
|
167
|
-
|
|
168
|
-
|
|
206
|
+
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
207
|
+
|
|
208
|
+
# logout the user
|
|
209
|
+
_user_logout(registry=_keycloak_registry,
|
|
210
|
+
user_id=user_id,
|
|
211
|
+
logger=_logger)
|
|
212
|
+
|
|
213
|
+
result: Response = Response(status=200)
|
|
214
|
+
|
|
215
|
+
# log the response
|
|
216
|
+
if _logger:
|
|
217
|
+
_logger.debug(msg=f"Response {result}")
|
|
218
|
+
|
|
219
|
+
return result
|
|
169
220
|
|
|
170
221
|
|
|
171
222
|
# @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
|
|
@@ -177,7 +228,79 @@ def service_callback() -> Response:
|
|
|
177
228
|
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
178
229
|
"""
|
|
179
230
|
global _keycloak_registry
|
|
180
|
-
|
|
231
|
+
from .token_pomes import token_validate
|
|
232
|
+
|
|
233
|
+
# log the request
|
|
234
|
+
if _logger:
|
|
235
|
+
msg: str = _log_init(request=request)
|
|
236
|
+
_logger.debug(msg=msg)
|
|
237
|
+
|
|
238
|
+
# validate the OAuth2 state
|
|
239
|
+
oauth_state: str = request.args.get("state")
|
|
240
|
+
user_id: str | None = None
|
|
241
|
+
user_data: dict[str, Any] | None = None
|
|
242
|
+
if oauth_state:
|
|
243
|
+
for user, data in _keycloak_registry.get("users").items():
|
|
244
|
+
safe_cache: FIFOCache = data.get("cache-obj")
|
|
245
|
+
if user == oauth_state:
|
|
246
|
+
if data.get("valid"):
|
|
247
|
+
user_id = user
|
|
248
|
+
user_data = data
|
|
249
|
+
else:
|
|
250
|
+
msg = "Operation timeout"
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
# exchange 'code' for the token
|
|
254
|
+
token: str | None = None
|
|
255
|
+
errors: list[str] = []
|
|
256
|
+
if user_data:
|
|
257
|
+
code: str = request.args.get("code")
|
|
258
|
+
body_data: dict[str, Any] = {
|
|
259
|
+
"grant_type": "authorization_code",
|
|
260
|
+
"code": code,
|
|
261
|
+
"redirec_url": _keycloak_registry.get("callback-url"),
|
|
262
|
+
}
|
|
263
|
+
# token = __post_jusbr(user_data=user_data,
|
|
264
|
+
# body_data=body_data,
|
|
265
|
+
# errors=errors,
|
|
266
|
+
# logger=_logger)
|
|
267
|
+
# retrieve the token's claims
|
|
268
|
+
if not errors:
|
|
269
|
+
public_key: bytes = _get_public_key(registry=_keycloak_registry,
|
|
270
|
+
url=_keycloak_registry["base-url"],
|
|
271
|
+
logger=_logger)
|
|
272
|
+
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
273
|
+
issuer=_keycloak_registry["base-url"],
|
|
274
|
+
public_key=public_key,
|
|
275
|
+
errors=errors,
|
|
276
|
+
logger=_logger)
|
|
277
|
+
if not errors:
|
|
278
|
+
token_user: str = token_claims["payload"].get("preferred_username")
|
|
279
|
+
if user_id == oauth_state:
|
|
280
|
+
user_id = token_user
|
|
281
|
+
_keycloak_registry["users"][user_id] = _keycloak_registry["users"].pop(oauth_state)
|
|
282
|
+
elif token_user != user_id:
|
|
283
|
+
errors.append(f"Token was issued to user '{token_user}'")
|
|
284
|
+
else:
|
|
285
|
+
msg: str = "Unknown OAuth2 code received"
|
|
286
|
+
if _get_login_timeout(registry=_keycloak_registry):
|
|
287
|
+
msg += " - possible operation timeout"
|
|
288
|
+
errors.append(msg)
|
|
289
|
+
|
|
290
|
+
result: Response
|
|
291
|
+
if errors:
|
|
292
|
+
result = jsonify({"errors": "; ".join(errors)})
|
|
293
|
+
result.status_code = 400
|
|
294
|
+
else:
|
|
295
|
+
result = jsonify({
|
|
296
|
+
"user_id": user_id,
|
|
297
|
+
"access_token": token})
|
|
298
|
+
|
|
299
|
+
# log the response
|
|
300
|
+
if _logger:
|
|
301
|
+
_logger.debug(msg=f"Response {result}")
|
|
302
|
+
|
|
303
|
+
return result
|
|
181
304
|
|
|
182
305
|
|
|
183
306
|
# @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
|
|
@@ -188,7 +311,12 @@ def service_token() -> Response:
|
|
|
188
311
|
|
|
189
312
|
:return: the response containing the token, or *UNAUTHORIZED*
|
|
190
313
|
"""
|
|
191
|
-
#
|
|
314
|
+
# log the request
|
|
315
|
+
if _logger:
|
|
316
|
+
msg: str = _log_init(request=request)
|
|
317
|
+
_logger.debug(msg=msg)
|
|
318
|
+
|
|
319
|
+
# retrieve the user id
|
|
192
320
|
input_params: dict[str, Any] = request.args
|
|
193
321
|
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
194
322
|
return Response()
|
|
@@ -210,4 +338,3 @@ def keycloak_get_token(user_id: str,
|
|
|
210
338
|
# initialize the return variable
|
|
211
339
|
result: str | None = None
|
|
212
340
|
return result
|
|
213
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_iam
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: A collection of Python pomes, penyeach (IAM modules)
|
|
5
5
|
Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-IAM
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-IAM/issues
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pypomes_iam/__init__.py,sha256=lHnqNqW1stQjcM6cr9wf3GGnw5_zGf1HN3zyHGb8PCA,577
|
|
2
|
+
pypomes_iam/common_pomes.py,sha256=kdzyEJX275SmMa_zi6AJaC9gVxlXcOailyantPvNOyQ,3908
|
|
3
|
+
pypomes_iam/jusbr_pomes.py,sha256=kNgAgQAMDdODoNO4XKrSggFwQ7R2ID-LLz7tmT3PXH4,19510
|
|
4
|
+
pypomes_iam/keycloak_pomes.py,sha256=m4jM_4c_McVg74T7JG7j3tbMo9Yxp6IKgt8TuauIp7o,13204
|
|
5
|
+
pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
|
|
6
|
+
pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
|
|
7
|
+
pypomes_iam-0.1.8.dist-info/METADATA,sha256=fjisTEC7XbvWn37I-2Jez6vtk7Uo83Q1WCjq172hOok,694
|
|
8
|
+
pypomes_iam-0.1.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
pypomes_iam-0.1.8.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
|
|
10
|
+
pypomes_iam-0.1.8.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
pypomes_iam/__init__.py,sha256=lHnqNqW1stQjcM6cr9wf3GGnw5_zGf1HN3zyHGb8PCA,577
|
|
2
|
-
pypomes_iam/jusbr_pomes.py,sha256=5igQW95f-Zv59w3tv8_wOfHfs6Lv2PB6-gLHpZFIc7s,21525
|
|
3
|
-
pypomes_iam/keycloak_pomes.py,sha256=4vLaYQNY9S9xHmyiv9Ii8jgL5jA1-MgAgWduicCyofw,8059
|
|
4
|
-
pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
|
|
5
|
-
pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
|
|
6
|
-
pypomes_iam-0.1.7.dist-info/METADATA,sha256=awYfm3GmoffocFbkPdNGnAecPQoleAcLCiCpD65LDZw,694
|
|
7
|
-
pypomes_iam-0.1.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
-
pypomes_iam-0.1.7.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
|
|
9
|
-
pypomes_iam-0.1.7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|