pypomes-iam 0.1.7__py3-none-any.whl → 0.1.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-iam might be problematic. Click here for more details.
- pypomes_iam/__init__.py +5 -0
- pypomes_iam/common_pomes.py +423 -0
- pypomes_iam/jusbr_pomes.py +65 -321
- pypomes_iam/keycloak_pomes.py +149 -53
- {pypomes_iam-0.1.7.dist-info → pypomes_iam-0.1.9.dist-info}/METADATA +2 -2
- pypomes_iam-0.1.9.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.9.dist-info}/WHEEL +0 -0
- {pypomes_iam-0.1.7.dist-info → pypomes_iam-0.1.9.dist-info}/licenses/LICENSE +0 -0
pypomes_iam/__init__.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from .jusbr_pomes import (
|
|
2
2
|
jusbr_setup, jusbr_get_token, jusbr_set_scope
|
|
3
3
|
)
|
|
4
|
+
from .keycloak_pomes import (
|
|
5
|
+
keycloak_setup, keycloak_get_token, keycloak_set_scope
|
|
6
|
+
)
|
|
4
7
|
from .provider_pomes import (
|
|
5
8
|
provider_register, provider_get_token
|
|
6
9
|
)
|
|
@@ -11,6 +14,8 @@ from .token_pomes import (
|
|
|
11
14
|
__all__ = [
|
|
12
15
|
# jusbr_pomes
|
|
13
16
|
"jusbr_setup", "jusbr_get_token", "jusbr_set_scope",
|
|
17
|
+
# keycloak_pomes
|
|
18
|
+
"keycloak_setup", "keycloak_get_token", "keycloak_set_scope",
|
|
14
19
|
# provider_pomes
|
|
15
20
|
"provider_register", "provider_get_token",
|
|
16
21
|
# token_pomes
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import requests
|
|
3
|
+
import secrets
|
|
4
|
+
import string
|
|
5
|
+
import sys
|
|
6
|
+
from cachetools import Cache
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from flask import Request
|
|
9
|
+
from logging import Logger
|
|
10
|
+
from pypomes_core import TZ_LOCAL, exc_format
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# registry structure:
|
|
14
|
+
# {
|
|
15
|
+
# "client-id": <str>,
|
|
16
|
+
# "client-secret": <str>,
|
|
17
|
+
# "client-timeout": <int>,
|
|
18
|
+
# "public_key": <str>,
|
|
19
|
+
# "key-lifetime": <int>,
|
|
20
|
+
# "key-expiration": <int>,
|
|
21
|
+
# "base-url": <str>,
|
|
22
|
+
# "callback-url": <str>,
|
|
23
|
+
# "safe-cache": <FIFOCache>
|
|
24
|
+
# }
|
|
25
|
+
# data in "safe-cache":
|
|
26
|
+
# {
|
|
27
|
+
# "users": {
|
|
28
|
+
# "<user-id>": {
|
|
29
|
+
# "access-token": <str>
|
|
30
|
+
# "refresh-token": <str>
|
|
31
|
+
# "access-expiration": <timestamp>,
|
|
32
|
+
# "login-expiration": <timestamp>, <-- transient
|
|
33
|
+
# "login-id": <str>, <-- transient
|
|
34
|
+
# "oauth-scope": <str> <-- optional
|
|
35
|
+
# }
|
|
36
|
+
# }
|
|
37
|
+
# }
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _service_callback(registry: dict[str, Any],
|
|
41
|
+
args: dict[str, Any],
|
|
42
|
+
errors: list[str],
|
|
43
|
+
logger: Logger | None) -> tuple[str, str]:
|
|
44
|
+
"""
|
|
45
|
+
Entry point for the callback from JusBR on authentication operation.
|
|
46
|
+
|
|
47
|
+
:param registry: the registry holding the authentication data
|
|
48
|
+
:param args: the arguments passed when requesting the service
|
|
49
|
+
:param errors: incidental errors
|
|
50
|
+
:param logger: optional logger
|
|
51
|
+
"""
|
|
52
|
+
from .token_pomes import token_validate
|
|
53
|
+
|
|
54
|
+
# initialize the return variable
|
|
55
|
+
result: tuple[str, str] | None = None
|
|
56
|
+
|
|
57
|
+
# retrieve the users authentication data
|
|
58
|
+
cache: Cache = registry["safe-cache"]
|
|
59
|
+
users: dict[str, dict[str, Any]] = cache.get("users")
|
|
60
|
+
|
|
61
|
+
# validate the OAuth2 state
|
|
62
|
+
oauth_state: str = args.get("state")
|
|
63
|
+
user_data: dict[str, Any] | None = None
|
|
64
|
+
if oauth_state:
|
|
65
|
+
for user, data in users.items():
|
|
66
|
+
if user == oauth_state:
|
|
67
|
+
user_data = data
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
# exchange 'code' for the token
|
|
71
|
+
if user_data:
|
|
72
|
+
users.pop(oauth_state)
|
|
73
|
+
code: str = args.get("code")
|
|
74
|
+
body_data: dict[str, Any] = {
|
|
75
|
+
"grant_type": "authorization_code",
|
|
76
|
+
"code": code,
|
|
77
|
+
"redirect_uri": registry.get("callback-url"),
|
|
78
|
+
}
|
|
79
|
+
token = _post_for_token(registry=registry,
|
|
80
|
+
user_data=user_data,
|
|
81
|
+
body_data=body_data,
|
|
82
|
+
errors=errors,
|
|
83
|
+
logger=logger)
|
|
84
|
+
# retrieve the token's claims
|
|
85
|
+
if not errors:
|
|
86
|
+
public_key: bytes = _get_public_key(registry=registry,
|
|
87
|
+
logger=logger)
|
|
88
|
+
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
89
|
+
issuer=registry["base-url"],
|
|
90
|
+
public_key=public_key,
|
|
91
|
+
errors=errors,
|
|
92
|
+
logger=logger)
|
|
93
|
+
if not errors:
|
|
94
|
+
token_user: str = token_claims["payload"].get("preferred_username")
|
|
95
|
+
if token_user == oauth_state:
|
|
96
|
+
users[token_user] = user_data
|
|
97
|
+
result = (token_user, token)
|
|
98
|
+
else:
|
|
99
|
+
errors.append(f"Token was issued to user '{token_user}'")
|
|
100
|
+
else:
|
|
101
|
+
msg: str = "Unknown OAuth2 code received"
|
|
102
|
+
if _get_login_timeout(registry=registry):
|
|
103
|
+
msg += " - possible operation timeout"
|
|
104
|
+
errors.append(msg)
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _service_login(registry: dict[str, Any],
|
|
110
|
+
args: dict[str, Any],
|
|
111
|
+
logger: Logger | None) -> str:
|
|
112
|
+
"""
|
|
113
|
+
Build the callback URL for redirecting the request to the IAM's authentication page.
|
|
114
|
+
|
|
115
|
+
:param registry: the registry holding the authentication data
|
|
116
|
+
:param args: the arguments passed when requesting the service
|
|
117
|
+
:param logger: optional logger
|
|
118
|
+
:return: the callback URL, with the appropriate parameters
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# retrieve user data
|
|
122
|
+
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
123
|
+
|
|
124
|
+
# build the user data
|
|
125
|
+
# ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
|
|
126
|
+
user_data: dict[str, Any] = _get_user_data(registry=registry,
|
|
127
|
+
user_id=oauth_state,
|
|
128
|
+
logger=logger)
|
|
129
|
+
user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
|
|
130
|
+
user_data["login-id"] = user_id
|
|
131
|
+
timeout: int = _get_login_timeout(registry=registry)
|
|
132
|
+
user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
|
|
133
|
+
|
|
134
|
+
# build the redirect url
|
|
135
|
+
result: str = (f"{registry["base-url"]}/protocol/openid-connect/auth?response_type=code"
|
|
136
|
+
f"&client_id={registry["client-id"]}"
|
|
137
|
+
f"&redirect_uri={registry["callback-url"]}"
|
|
138
|
+
f"&state={oauth_state}")
|
|
139
|
+
scope: str = _get_user_scope(registry=registry,
|
|
140
|
+
user_id=user_id)
|
|
141
|
+
if scope:
|
|
142
|
+
user_data["oauth-scope"] = scope
|
|
143
|
+
result += f"&scope={scope}"
|
|
144
|
+
|
|
145
|
+
# logout the user
|
|
146
|
+
_service_logout(registry=registry,
|
|
147
|
+
args=args,
|
|
148
|
+
logger=logger)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _service_logout(registry: dict[str, Any],
|
|
153
|
+
args: dict[str, Any],
|
|
154
|
+
logger: Logger | None) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Remove all data associating *user_id* from *registry*.
|
|
157
|
+
|
|
158
|
+
:param registry: the registry holding the authentication data
|
|
159
|
+
:param args: the arguments passed when requesting the service
|
|
160
|
+
:param logger: optional logger
|
|
161
|
+
"""
|
|
162
|
+
# remove the user data
|
|
163
|
+
user_id: str = args.get("user-id") or args.get("login")
|
|
164
|
+
if user_id:
|
|
165
|
+
cache: Cache = registry["safe-cache"]
|
|
166
|
+
users: dict[str, dict[str, Any]] = cache.get("users")
|
|
167
|
+
if user_id in users:
|
|
168
|
+
users.pop(user_id)
|
|
169
|
+
if logger:
|
|
170
|
+
logger.debug(msg=f"User '{user_id}' removed from the registry")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _service_token(registry: dict[str, Any],
|
|
174
|
+
args: dict[str, Any],
|
|
175
|
+
errors: list[str] = None,
|
|
176
|
+
logger: Logger = None) -> str:
|
|
177
|
+
"""
|
|
178
|
+
Retrieve the authentication token for user *user_id*.
|
|
179
|
+
|
|
180
|
+
:param registry: the registry holding the authentication data
|
|
181
|
+
:param args: the arguments passed when requesting the service
|
|
182
|
+
:param errors: incidental error messages
|
|
183
|
+
:param logger: optional logger
|
|
184
|
+
:return: the token for *user_id*, or *None* if error
|
|
185
|
+
"""
|
|
186
|
+
# initialize the return variable
|
|
187
|
+
result: str | None = None
|
|
188
|
+
|
|
189
|
+
user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
|
|
190
|
+
user_data: dict[str, Any] = _get_user_data(registry=registry,
|
|
191
|
+
user_id=user_id,
|
|
192
|
+
logger=logger)
|
|
193
|
+
token: str = user_data["access-token"]
|
|
194
|
+
if token:
|
|
195
|
+
access_expiration: int = user_data.get("access-expiration")
|
|
196
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
197
|
+
if now < access_expiration:
|
|
198
|
+
result = token
|
|
199
|
+
else:
|
|
200
|
+
# access token has expired
|
|
201
|
+
refresh_token: str = user_data["refresh-token"]
|
|
202
|
+
if refresh_token:
|
|
203
|
+
body_data: dict[str, str] = {
|
|
204
|
+
"grant_type": "refresh_token",
|
|
205
|
+
"refresh_token": refresh_token
|
|
206
|
+
}
|
|
207
|
+
result = _post_for_token(registry=registry,
|
|
208
|
+
user_data=user_data,
|
|
209
|
+
body_data=body_data,
|
|
210
|
+
errors=errors,
|
|
211
|
+
logger=logger)
|
|
212
|
+
|
|
213
|
+
elif logger or isinstance(errors, list):
|
|
214
|
+
err_msg: str = f"User '{user_id}' not authenticated"
|
|
215
|
+
if isinstance(errors, list):
|
|
216
|
+
errors.append(err_msg)
|
|
217
|
+
if logger:
|
|
218
|
+
logger.error(msg=err_msg)
|
|
219
|
+
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_public_key(registry: dict[str, Any],
|
|
224
|
+
logger: Logger | None) -> bytes:
|
|
225
|
+
"""
|
|
226
|
+
Obtain the public key used by the *IAM* to sign the authentication tokens.
|
|
227
|
+
|
|
228
|
+
The public key is saved in *registry*.
|
|
229
|
+
|
|
230
|
+
:param registry: the registry holding the authentication data
|
|
231
|
+
:return: the public key, in *DER* format
|
|
232
|
+
"""
|
|
233
|
+
from pypomes_crypto import crypto_jwk_convert
|
|
234
|
+
|
|
235
|
+
# initialize the return variable
|
|
236
|
+
result: bytes | None = None
|
|
237
|
+
|
|
238
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
239
|
+
if now > registry["key-expiration"]:
|
|
240
|
+
# obtain a new public key
|
|
241
|
+
url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
|
|
242
|
+
if logger:
|
|
243
|
+
logger.debug(msg=f"GET '{url}'")
|
|
244
|
+
response: requests.Response = requests.get(url=url)
|
|
245
|
+
if response.status_code == 200:
|
|
246
|
+
# request succeeded
|
|
247
|
+
if logger:
|
|
248
|
+
logger.debug(msg=f"GET success, status {response.status_code}")
|
|
249
|
+
reply: dict[str, Any] = response.json()
|
|
250
|
+
result = crypto_jwk_convert(jwk=reply["keys"][0],
|
|
251
|
+
fmt="DER")
|
|
252
|
+
registry["public-key"] = result
|
|
253
|
+
duration: int = registry["key-lifetime"] or 0
|
|
254
|
+
registry["key-expiration"] = now + duration
|
|
255
|
+
elif logger:
|
|
256
|
+
msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
|
|
257
|
+
if hasattr(response, "content") and response.content:
|
|
258
|
+
msg += f", content '{response.content}'"
|
|
259
|
+
logger.error(msg=msg)
|
|
260
|
+
else:
|
|
261
|
+
result = registry["public-key"]
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _get_login_timeout(registry: dict[str, Any]) -> int | None:
|
|
267
|
+
"""
|
|
268
|
+
Retrieve from *registry* the timeout currently applicable for the login operation.
|
|
269
|
+
|
|
270
|
+
:param registry: the registry holding the authentication data
|
|
271
|
+
:return: the current login timeout, or *None* if none has been set.
|
|
272
|
+
"""
|
|
273
|
+
timeout: int = registry.get("client-timeout")
|
|
274
|
+
return timeout if isinstance(timeout, int) and timeout > 0 else None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _get_user_data(registry: dict[str, Any],
|
|
278
|
+
user_id: str,
|
|
279
|
+
logger: Logger | None) -> dict[str, Any]:
|
|
280
|
+
"""
|
|
281
|
+
Retrieve the data for *user_id* from *registry*.
|
|
282
|
+
|
|
283
|
+
If an entry is not found for *user_id* in the registry, it is created.
|
|
284
|
+
It will remain there until the user is logged out.
|
|
285
|
+
|
|
286
|
+
:param registry: the registry holding the authentication data
|
|
287
|
+
:return: the data for *user_id* in the registry
|
|
288
|
+
"""
|
|
289
|
+
cache: Cache = registry["safe-cache"]
|
|
290
|
+
users: dict[str, dict[str, Any]] = cache.get("users")
|
|
291
|
+
result: dict[str, Any] = users.get(user_id)
|
|
292
|
+
if not result:
|
|
293
|
+
result = {
|
|
294
|
+
"access-token": None,
|
|
295
|
+
"refresh-token": None,
|
|
296
|
+
"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
297
|
+
}
|
|
298
|
+
users[user_id] = result
|
|
299
|
+
if logger:
|
|
300
|
+
logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
|
|
301
|
+
elif logger:
|
|
302
|
+
logger.debug(msg=f"Entry for user '{user_id}' obtained from the registry")
|
|
303
|
+
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _get_user_scope(registry: dict[str, Any],
|
|
308
|
+
user_id: str) -> str | None:
|
|
309
|
+
"""
|
|
310
|
+
Retrieve the OAuth2 scope associated with *user_id*.
|
|
311
|
+
|
|
312
|
+
:param registry: the registry holding the authentication data
|
|
313
|
+
:param user_id:
|
|
314
|
+
:return: the OAuth2 scope associated with *user_id*, or *None* if it does not exist
|
|
315
|
+
"""
|
|
316
|
+
# initialize the return variable
|
|
317
|
+
result: str | None = None
|
|
318
|
+
|
|
319
|
+
if user_id:
|
|
320
|
+
cache: Cache = registry["safe-cache"]
|
|
321
|
+
users: dict[str, dict[str, Any]] = cache.get("users")
|
|
322
|
+
if user_id in users:
|
|
323
|
+
result = users[user_id].get("oauth2-scope")
|
|
324
|
+
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _post_for_token(registry: dict[str, Any],
|
|
329
|
+
user_data: dict[str, Any],
|
|
330
|
+
body_data: dict[str, Any],
|
|
331
|
+
errors: list[str] | None,
|
|
332
|
+
logger: Logger | None) -> str | None:
|
|
333
|
+
"""
|
|
334
|
+
Send a POST request to obtain the authentication token data, and return the access token.
|
|
335
|
+
|
|
336
|
+
For token exchange, *body_data* will have the attributes
|
|
337
|
+
- "grant_type": "authorization_code"
|
|
338
|
+
- "code": <16-character-random-code>
|
|
339
|
+
- "redirect_uri": <callback-url>
|
|
340
|
+
For token refresh, *body_data* will have the attributes
|
|
341
|
+
- "grant_type": "refresh_token"
|
|
342
|
+
- "refresh_token": <current-refresh-token>
|
|
343
|
+
|
|
344
|
+
If the operation is successful, the token data is stored in the registry.
|
|
345
|
+
Otherwise, *errors* will contain the appropriate error message.
|
|
346
|
+
|
|
347
|
+
:param registry: the registry holding the authentication data
|
|
348
|
+
:param user_data: the user's data in the registry
|
|
349
|
+
:param body_data: the data to send in the body of the request
|
|
350
|
+
:param errors: incidental errors
|
|
351
|
+
:param logger: optional logger
|
|
352
|
+
:return: the access token obtained, or *None* if error
|
|
353
|
+
"""
|
|
354
|
+
# initialize the return variable
|
|
355
|
+
result: str | None = None
|
|
356
|
+
|
|
357
|
+
# complete the data to send in body of request
|
|
358
|
+
body_data["client_id"] = registry["client-id"]
|
|
359
|
+
client_secret: str = registry["client-secret"]
|
|
360
|
+
if client_secret:
|
|
361
|
+
body_data["client_secret"] = client_secret
|
|
362
|
+
|
|
363
|
+
# obtain the token
|
|
364
|
+
err_msg: str | None = None
|
|
365
|
+
url: str = registry["base-url"] + "/protocol/openid-connect/token"
|
|
366
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
367
|
+
if logger:
|
|
368
|
+
logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
|
|
369
|
+
ensure_ascii=False)}")
|
|
370
|
+
try:
|
|
371
|
+
# typical return on a token request:
|
|
372
|
+
# {
|
|
373
|
+
# "token_type": "Bearer",
|
|
374
|
+
# "access_token": <str>,
|
|
375
|
+
# "expires_in": <number-of-seconds>,
|
|
376
|
+
# "refresh_token": <str>
|
|
377
|
+
# }
|
|
378
|
+
response: requests.Response = requests.post(url=url,
|
|
379
|
+
data=body_data)
|
|
380
|
+
if response.status_code == 200:
|
|
381
|
+
# request succeeded
|
|
382
|
+
if logger:
|
|
383
|
+
logger.debug(msg=f"POST success, status {response.status_code}")
|
|
384
|
+
reply: dict[str, Any] = response.json()
|
|
385
|
+
result = reply.get("access_token")
|
|
386
|
+
user_data["access-token"] = result
|
|
387
|
+
# on token refresh, keep current refresh token if a new one is not provided
|
|
388
|
+
user_data["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
|
|
389
|
+
user_data["access-expiration"] = now + reply.get("expires_in")
|
|
390
|
+
else:
|
|
391
|
+
# request resulted in error
|
|
392
|
+
err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
|
|
393
|
+
if hasattr(response, "content") and response.content:
|
|
394
|
+
err_msg += f", content '{response.content}'"
|
|
395
|
+
if response.status_code == 400 and body_data.get("grant_type") == "refresh_token":
|
|
396
|
+
# refresh token is no longer valid
|
|
397
|
+
user_data["refresh-token"] = None
|
|
398
|
+
except Exception as e:
|
|
399
|
+
# the operation raised an exception
|
|
400
|
+
err_msg = exc_format(exc=e,
|
|
401
|
+
exc_info=sys.exc_info())
|
|
402
|
+
err_msg = f"POST '{url}': error '{err_msg}'"
|
|
403
|
+
|
|
404
|
+
if err_msg:
|
|
405
|
+
if isinstance(errors, list):
|
|
406
|
+
errors.append(err_msg)
|
|
407
|
+
if logger:
|
|
408
|
+
logger.error(msg=err_msg)
|
|
409
|
+
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _log_init(request: Request) -> str:
|
|
414
|
+
"""
|
|
415
|
+
Build the messages for logging the request entry.
|
|
416
|
+
|
|
417
|
+
:param request: the Request object
|
|
418
|
+
:return: the log message
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
params: str = json.dumps(obj=request.args,
|
|
422
|
+
ensure_ascii=False)
|
|
423
|
+
return f"Request {request.method}:{request.path}, params {params}"
|
pypomes_iam/jusbr_pomes.py
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import
|
|
2
|
-
import requests
|
|
3
|
-
import secrets
|
|
4
|
-
import string
|
|
5
|
-
import sys
|
|
6
|
-
from cachetools import Cache, FIFOCache, TTLCache
|
|
1
|
+
from cachetools import FIFOCache
|
|
7
2
|
from datetime import datetime
|
|
8
|
-
from flask import Flask,
|
|
3
|
+
from flask import Flask, Response, redirect, request, jsonify
|
|
9
4
|
from logging import Logger
|
|
10
5
|
from pypomes_core import (
|
|
11
|
-
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
|
|
6
|
+
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
|
|
12
7
|
)
|
|
13
8
|
from typing import Any, Final
|
|
14
9
|
|
|
10
|
+
from .common_pomes import (
|
|
11
|
+
_service_login, _service_logout,
|
|
12
|
+
_service_callback, _service_token,
|
|
13
|
+
_get_user_data, _log_init
|
|
14
|
+
)
|
|
15
|
+
|
|
15
16
|
JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
|
|
16
17
|
JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
|
|
17
18
|
JUSBR_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_CLIENT_TIMEOUT")
|
|
@@ -36,18 +37,22 @@ JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_A
|
|
|
36
37
|
# "client-secret": <str>,
|
|
37
38
|
# "client-timeout": <int>,
|
|
38
39
|
# "public_key": <str>,
|
|
40
|
+
# "key-lifetime": <int>,
|
|
39
41
|
# "key-expiration": <int>,
|
|
40
|
-
# "
|
|
42
|
+
# "base-url": <str>,
|
|
41
43
|
# "callback-url": <str>,
|
|
44
|
+
# "cache-obj": <FIFOCache>
|
|
45
|
+
# }
|
|
46
|
+
# data in "cache-obj":
|
|
47
|
+
# {
|
|
42
48
|
# "users": {
|
|
43
49
|
# "<user-id>": {
|
|
44
|
-
# "
|
|
45
|
-
# "
|
|
50
|
+
# "access-token": <str>
|
|
51
|
+
# "refresh-token": <str>
|
|
46
52
|
# "access-expiration": <timestamp>,
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# "refresh-token": <str>
|
|
53
|
+
# "login-expiration": <timestamp>, <-- transient
|
|
54
|
+
# "login-id": <str>, <-- transient
|
|
55
|
+
# "oauth-scope": <str> <-- optional
|
|
51
56
|
# }
|
|
52
57
|
# }
|
|
53
58
|
# }
|
|
@@ -66,7 +71,7 @@ def jusbr_setup(flask_app: Flask,
|
|
|
66
71
|
token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
|
|
67
72
|
login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
|
|
68
73
|
logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
|
|
69
|
-
|
|
74
|
+
base_url: str = JUSBR_URL_AUTH_BASE,
|
|
70
75
|
callback_url: str = JUSBR_URL_AUTH_CALLBACK,
|
|
71
76
|
logger: Logger = None) -> None:
|
|
72
77
|
"""
|
|
@@ -83,7 +88,7 @@ def jusbr_setup(flask_app: Flask,
|
|
|
83
88
|
:param token_endpoint: endpoint for retrieving the JusBR authentication token
|
|
84
89
|
:param login_endpoint: endpoint for redirecting user to JusBR login page
|
|
85
90
|
:param logout_endpoint: endpoint for terminating user access to JusBR
|
|
86
|
-
:param
|
|
91
|
+
:param base_url: base URL to request the JusBR services
|
|
87
92
|
:param callback_url: URL for JusBR to callback on login
|
|
88
93
|
:param logger: optional logger
|
|
89
94
|
"""
|
|
@@ -97,11 +102,11 @@ def jusbr_setup(flask_app: Flask,
|
|
|
97
102
|
"client-id": client_id,
|
|
98
103
|
"client-secret": client_secret,
|
|
99
104
|
"client-timeout": client_timeout,
|
|
100
|
-
"
|
|
105
|
+
"base-url": base_url,
|
|
101
106
|
"callback-url": callback_url,
|
|
102
107
|
"key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
|
|
103
108
|
"key-lifetime": public_key_lifetime,
|
|
104
|
-
"
|
|
109
|
+
"cache-obj": FIFOCache(maxsize=1048576)
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
# establish the endpoints
|
|
@@ -141,33 +146,12 @@ def service_login() -> Response:
|
|
|
141
146
|
|
|
142
147
|
# log the request
|
|
143
148
|
if _logger:
|
|
144
|
-
msg
|
|
145
|
-
_logger.debug(msg=msg)
|
|
146
|
-
|
|
147
|
-
# retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
|
|
148
|
-
input_params: dict[str, Any] = request.values
|
|
149
|
-
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
150
|
-
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] = __get_user_data(user_id=user_id,
|
|
153
|
-
logger=_logger)
|
|
154
|
-
# build redirect url
|
|
155
|
-
timeout: int = __get_login_timeout()
|
|
156
|
-
safe_cache: Cache
|
|
157
|
-
if timeout:
|
|
158
|
-
safe_cache = TTLCache(maxsize=16,
|
|
159
|
-
ttl=600)
|
|
160
|
-
else:
|
|
161
|
-
safe_cache = FIFOCache(maxsize=16)
|
|
162
|
-
safe_cache["oauth-state"] = oauth_state
|
|
163
|
-
user_data["cache-obj"] = safe_cache
|
|
164
|
-
auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
|
|
165
|
-
f"&client_id={_jusbr_registry["client-id"]}"
|
|
166
|
-
f"&redirect_uri={_jusbr_registry["callback-url"]}"
|
|
167
|
-
f"&state={oauth_state}")
|
|
168
|
-
if user_data.get("oauth-scope"):
|
|
169
|
-
auth_url += f"&scope={user_data.get("oauth-scope")}"
|
|
149
|
+
_logger.debug(msg=_log_init(request=request))
|
|
170
150
|
|
|
151
|
+
# obtain the redirect URL
|
|
152
|
+
auth_url: str = _service_login(registry=_jusbr_registry,
|
|
153
|
+
args=request.args,
|
|
154
|
+
logger=_logger)
|
|
171
155
|
# redirect the request
|
|
172
156
|
result: Response = redirect(location=auth_url)
|
|
173
157
|
|
|
@@ -192,18 +176,12 @@ def service_logout() -> Response:
|
|
|
192
176
|
|
|
193
177
|
# log the request
|
|
194
178
|
if _logger:
|
|
195
|
-
msg
|
|
196
|
-
_logger.debug(msg=msg)
|
|
197
|
-
|
|
198
|
-
# retrieve user id
|
|
199
|
-
input_params: dict[str, Any] = request.args
|
|
200
|
-
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
179
|
+
_logger.debug(msg=_log_init(request=request))
|
|
201
180
|
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
_logger.debug(f"User '{user_id}' removed from the registry")
|
|
181
|
+
# logout the user
|
|
182
|
+
_service_logout(registry=_jusbr_registry,
|
|
183
|
+
args=request.args,
|
|
184
|
+
logger=_logger)
|
|
207
185
|
|
|
208
186
|
result: Response = Response(status=200)
|
|
209
187
|
|
|
@@ -220,73 +198,28 @@ def service_callback() -> Response:
|
|
|
220
198
|
"""
|
|
221
199
|
Entry point for the callback from JusBR on authentication operation.
|
|
222
200
|
|
|
223
|
-
:return: the response containing the token, or *
|
|
201
|
+
:return: the response containing the token, or *BAD REQUEST*
|
|
224
202
|
"""
|
|
225
203
|
global _jusbr_registry
|
|
226
|
-
from .token_pomes import token_validate
|
|
227
204
|
|
|
228
205
|
# log the request
|
|
229
206
|
if _logger:
|
|
230
|
-
msg
|
|
231
|
-
_logger.debug(msg=msg)
|
|
232
|
-
|
|
233
|
-
# validate the OAuth2 state
|
|
234
|
-
oauth_state: str = request.args.get("state")
|
|
235
|
-
user_id: str | None = None
|
|
236
|
-
user_data: dict[str, Any] | None = None
|
|
237
|
-
if oauth_state:
|
|
238
|
-
for user, data in _jusbr_registry.get("users").items():
|
|
239
|
-
safe_cache: Cache = data.get("cache-obj")
|
|
240
|
-
if user == oauth_state or \
|
|
241
|
-
(safe_cache and oauth_state == safe_cache.get("oauth-state")):
|
|
242
|
-
user_id = user
|
|
243
|
-
user_data = data
|
|
244
|
-
# 'oauth-state' is to be used only once
|
|
245
|
-
safe_cache["oauth-state"] = None
|
|
246
|
-
break
|
|
247
|
-
|
|
248
|
-
# exchange 'code' for the token
|
|
249
|
-
token: str | None = None
|
|
250
|
-
errors: list[str] = []
|
|
251
|
-
if user_data:
|
|
252
|
-
code: str = request.args.get("code")
|
|
253
|
-
body_data: dict[str, Any] = {
|
|
254
|
-
"grant_type": "authorization_code",
|
|
255
|
-
"code": code,
|
|
256
|
-
"redirec_url": _jusbr_registry.get("callback-url"),
|
|
257
|
-
}
|
|
258
|
-
token = __post_jusbr(user_data=user_data,
|
|
259
|
-
body_data=body_data,
|
|
260
|
-
errors=errors,
|
|
261
|
-
logger=_logger)
|
|
262
|
-
# retrieve the token's claims
|
|
263
|
-
if not errors:
|
|
264
|
-
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
265
|
-
issuer=_jusbr_registry.get("auth-url"),
|
|
266
|
-
public_key=_jusbr_registry.get("public_key"),
|
|
267
|
-
errors=errors,
|
|
268
|
-
logger=_logger)
|
|
269
|
-
if not errors:
|
|
270
|
-
token_user: str = token_claims["payload"].get("preferred_username")
|
|
271
|
-
if user_id == oauth_state:
|
|
272
|
-
user_id = token_user
|
|
273
|
-
_jusbr_registry["users"][user_id] = _jusbr_registry["users"].pop(oauth_state)
|
|
274
|
-
elif token_user != user_id:
|
|
275
|
-
errors.append(f"Token was issued to user '{token_user}'")
|
|
276
|
-
else:
|
|
277
|
-
msg: str = "Unknown OAuth2 code received"
|
|
278
|
-
if __get_login_timeout():
|
|
279
|
-
msg += " - possible operation timeout"
|
|
280
|
-
errors.append(msg)
|
|
207
|
+
_logger.debug(msg=_log_init(request=request))
|
|
281
208
|
|
|
209
|
+
# process the callback operation
|
|
210
|
+
errors: list[str] = []
|
|
211
|
+
token_data: tuple[str, str] = _service_callback(registry=_jusbr_registry,
|
|
212
|
+
args=request.args,
|
|
213
|
+
errors=errors,
|
|
214
|
+
logger=_logger)
|
|
282
215
|
result: Response
|
|
283
216
|
if errors:
|
|
284
217
|
result = jsonify({"errors": "; ".join(errors)})
|
|
285
218
|
result.status_code = 400
|
|
286
219
|
else:
|
|
287
220
|
result = jsonify({
|
|
288
|
-
"user_id":
|
|
289
|
-
"access_token":
|
|
221
|
+
"user_id": token_data[0],
|
|
222
|
+
"access_token": token_data[1]})
|
|
290
223
|
|
|
291
224
|
# log the response
|
|
292
225
|
if _logger:
|
|
@@ -303,17 +236,18 @@ def service_token() -> Response:
|
|
|
303
236
|
|
|
304
237
|
:return: the response containing the token, or *UNAUTHORIZED*
|
|
305
238
|
"""
|
|
239
|
+
global _jusbr_registry
|
|
240
|
+
|
|
306
241
|
# log the request
|
|
307
242
|
if _logger:
|
|
308
|
-
msg
|
|
309
|
-
_logger.debug(msg=msg)
|
|
243
|
+
_logger.debug(msg=_log_init(request=request))
|
|
310
244
|
|
|
311
245
|
# retrieve the token
|
|
312
|
-
input_params: dict[str, Any] = request.args
|
|
313
|
-
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
314
246
|
errors: list[str] = []
|
|
315
|
-
token: str =
|
|
316
|
-
|
|
247
|
+
token: str = _service_token(registry=_jusbr_registry,
|
|
248
|
+
args=request.args,
|
|
249
|
+
errors=errors,
|
|
250
|
+
logger=_logger)
|
|
317
251
|
result: Response
|
|
318
252
|
if token:
|
|
319
253
|
result = jsonify({"token": token})
|
|
@@ -332,53 +266,26 @@ def jusbr_get_token(user_id: str,
|
|
|
332
266
|
errors: list[str] = None,
|
|
333
267
|
logger: Logger = None) -> str:
|
|
334
268
|
"""
|
|
335
|
-
Retrieve
|
|
269
|
+
Retrieve a JusBR authentication token for *user_id*.
|
|
336
270
|
|
|
337
271
|
:param user_id: the user's identification
|
|
338
|
-
:param errors: incidental
|
|
272
|
+
:param errors: incidental errors
|
|
339
273
|
:param logger: optional logger
|
|
340
|
-
:return: the
|
|
274
|
+
:return: the uthentication tokem
|
|
341
275
|
"""
|
|
342
276
|
global _jusbr_registry
|
|
343
277
|
|
|
344
|
-
#
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if safe_cache:
|
|
351
|
-
access_expiration: int = user_data.get("access-expiration")
|
|
352
|
-
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
353
|
-
if now < access_expiration:
|
|
354
|
-
result = safe_cache.get("access-token")
|
|
355
|
-
else:
|
|
356
|
-
# access token has expired
|
|
357
|
-
safe_cache["access-token"] = None
|
|
358
|
-
refresh_token: str = safe_cache.get("refresh-token")
|
|
359
|
-
if refresh_token:
|
|
360
|
-
body_data: dict[str, str] = {
|
|
361
|
-
"grant_type": "refresh_token",
|
|
362
|
-
"refresh_token": refresh_token
|
|
363
|
-
}
|
|
364
|
-
result = __post_jusbr(user_data=user_data,
|
|
365
|
-
body_data=body_data,
|
|
366
|
-
errors=errors,
|
|
367
|
-
logger=logger)
|
|
368
|
-
|
|
369
|
-
elif logger or isinstance(errors, list):
|
|
370
|
-
err_msg: str = f"User '{user_id}' not authenticated with JusBR"
|
|
371
|
-
if isinstance(errors, list):
|
|
372
|
-
errors.append(err_msg)
|
|
373
|
-
if logger:
|
|
374
|
-
logger.error(msg=err_msg)
|
|
375
|
-
|
|
376
|
-
return result
|
|
278
|
+
# retrieve the token
|
|
279
|
+
args: dict[str, Any] = {"user-id": user_id}
|
|
280
|
+
return _service_token(registry=_jusbr_registry,
|
|
281
|
+
args=args,
|
|
282
|
+
errors=errors,
|
|
283
|
+
logger=logger)
|
|
377
284
|
|
|
378
285
|
|
|
379
286
|
def jusbr_set_scope(user_id: str,
|
|
380
287
|
scope: str,
|
|
381
|
-
logger: Logger
|
|
288
|
+
logger: Logger = None) -> None:
|
|
382
289
|
"""
|
|
383
290
|
Set the OAuth2 scope of *user_id* to *scope*.
|
|
384
291
|
|
|
@@ -389,173 +296,10 @@ def jusbr_set_scope(user_id: str,
|
|
|
389
296
|
global _jusbr_registry
|
|
390
297
|
|
|
391
298
|
# retrieve user data
|
|
392
|
-
user_data: dict[str, Any] =
|
|
393
|
-
|
|
299
|
+
user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
|
|
300
|
+
user_id=user_id,
|
|
301
|
+
logger=logger)
|
|
394
302
|
# set the OAuth2 scope
|
|
395
303
|
user_data["oauth-scope"] = scope
|
|
396
304
|
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
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def __post_jusbr(user_data: dict[str, Any],
|
|
472
|
-
body_data: dict[str, Any],
|
|
473
|
-
errors: list[str] | None,
|
|
474
|
-
logger: Logger | None) -> str | None:
|
|
475
|
-
"""
|
|
476
|
-
Send a POST request to JusBR to obtain the authentication token data, and return the access token.
|
|
477
|
-
|
|
478
|
-
For code for token exchange, *body_data* will have the attributes
|
|
479
|
-
- "grant_type": "authorization_code"
|
|
480
|
-
- "code": <16-character-random-code>
|
|
481
|
-
- "redirect_uri": <callback-url>
|
|
482
|
-
For token refresh, *body_data* will have the attributes
|
|
483
|
-
- "grant_type": "refresh_token"
|
|
484
|
-
- "refresh_token": <current-refresh-token>
|
|
485
|
-
|
|
486
|
-
If the operation is successful, the token data is stored in the registry.
|
|
487
|
-
Otherwise, *errors* will contain the appropriate error message.
|
|
488
|
-
|
|
489
|
-
:param user_data: the user's data in the registry
|
|
490
|
-
:param body_data: the data to send in the body of the request
|
|
491
|
-
:param errors: incidental errors
|
|
492
|
-
:param logger: optional logger
|
|
493
|
-
:return: the access token obtained, or *None* if error
|
|
494
|
-
"""
|
|
495
|
-
global _jusbr_registry
|
|
496
|
-
|
|
497
|
-
# initialize the return variable
|
|
498
|
-
result: str | None = None
|
|
499
|
-
|
|
500
|
-
# complete the data to send in body of request
|
|
501
|
-
body_data["client_id"] = _jusbr_registry.get("client-id")
|
|
502
|
-
client_secret: str = _jusbr_registry.get("client-secret")
|
|
503
|
-
if client_secret:
|
|
504
|
-
body_data["client_secret"] = client_secret
|
|
505
|
-
|
|
506
|
-
# obtain the token
|
|
507
|
-
err_msg: str | None = None
|
|
508
|
-
safe_cache: Cache = user_data.get("cache-obj")
|
|
509
|
-
url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
|
|
510
|
-
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
511
|
-
try:
|
|
512
|
-
# JusBR return on a token request:
|
|
513
|
-
# {
|
|
514
|
-
# "token_type": "Bearer",
|
|
515
|
-
# "access_token": <str>,
|
|
516
|
-
# "expires_in": <number-of-seconds>,
|
|
517
|
-
# "refresh_token": <str>,
|
|
518
|
-
# }
|
|
519
|
-
response: requests.Response = requests.post(url=url,
|
|
520
|
-
data=body_data)
|
|
521
|
-
if response.status_code == 200:
|
|
522
|
-
# request succeeded
|
|
523
|
-
reply: dict[str, Any] = response.json()
|
|
524
|
-
result = reply.get("access_token")
|
|
525
|
-
safe_cache: Cache = FIFOCache(maxsize=1024)
|
|
526
|
-
safe_cache["access-token"] = result
|
|
527
|
-
# on token refresh, keep current refresh token if a new one is not provided
|
|
528
|
-
safe_cache["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
|
|
529
|
-
user_data["cache-obj"] = safe_cache
|
|
530
|
-
user_data["access-expiration"] = now + reply.get("expires_in")
|
|
531
|
-
if logger:
|
|
532
|
-
logger.debug(msg=f"POST '{url}': status {response.status_code}")
|
|
533
|
-
else:
|
|
534
|
-
# request resulted in error
|
|
535
|
-
err_msg = (f"POST '{url}': failed, "
|
|
536
|
-
f"status {response.status_code}, reason '{response.reason}'")
|
|
537
|
-
if hasattr(response, "content") and response.content:
|
|
538
|
-
err_msg += f", content '{response.content}'"
|
|
539
|
-
if response.status_code == 401 and "refresh_token" in body_data:
|
|
540
|
-
# refresh token is no longer valid
|
|
541
|
-
safe_cache["refresh-token"] = None
|
|
542
|
-
except Exception as e:
|
|
543
|
-
# the operation raised an exception
|
|
544
|
-
err_msg = exc_format(exc=e,
|
|
545
|
-
exc_info=sys.exc_info())
|
|
546
|
-
err_msg = f"POST '{url}': error '{err_msg}'"
|
|
547
|
-
|
|
548
|
-
if err_msg:
|
|
549
|
-
if isinstance(errors, list):
|
|
550
|
-
errors.append(err_msg)
|
|
551
|
-
if logger:
|
|
552
|
-
logger.error(msg=err_msg)
|
|
553
|
-
|
|
554
|
-
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}"
|
|
305
|
+
logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
|
pypomes_iam/keycloak_pomes.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
# import string
|
|
4
|
-
# import sys
|
|
5
|
-
# from cachetools import Cache, FIFOCache, TTLCache
|
|
6
|
-
# from datetime import datetime
|
|
1
|
+
from cachetools import FIFOCache
|
|
2
|
+
from datetime import datetime
|
|
7
3
|
from flask import Flask, Response, redirect, request, jsonify
|
|
8
4
|
from logging import Logger
|
|
9
5
|
from pypomes_core import (
|
|
10
|
-
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
|
|
6
|
+
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
|
|
11
7
|
)
|
|
12
8
|
from typing import Any, Final
|
|
13
9
|
|
|
10
|
+
from .common_pomes import (
|
|
11
|
+
_service_login, _service_logout,
|
|
12
|
+
_service_callback, _service_token,
|
|
13
|
+
_get_user_data, _log_init
|
|
14
|
+
)
|
|
15
|
+
|
|
14
16
|
KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
|
|
15
17
|
KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
|
|
16
18
|
KEYCLOAK_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_TIMEOUT")
|
|
@@ -24,6 +26,8 @@ KEYCLOAK_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_E
|
|
|
24
26
|
KEYCLOAK_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_TOKEN",
|
|
25
27
|
def_value="/iam/keycloak:get-token")
|
|
26
28
|
|
|
29
|
+
KEYCLOAK_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_PUBLIC_KEY_LIFETIME",
|
|
30
|
+
def_value=86400) # 24 hours
|
|
27
31
|
KEYCLOAK_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_REALM")
|
|
28
32
|
KEYCLOAK_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_BASE")
|
|
29
33
|
KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_CALLBACK")
|
|
@@ -33,30 +37,27 @@ KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK
|
|
|
33
37
|
# "client-id": <str>,
|
|
34
38
|
# "client-secret": <str>,
|
|
35
39
|
# "client-timeout": <int>,
|
|
36
|
-
# "
|
|
37
|
-
# "
|
|
40
|
+
# "public_key": <str>,
|
|
41
|
+
# "key-lifetime": <int>,
|
|
42
|
+
# "key-expiration": <int>,
|
|
43
|
+
# "base-url": <str>,
|
|
38
44
|
# "callback-url": <str>,
|
|
45
|
+
# "safe-cache": <FIFOCache>
|
|
46
|
+
# }
|
|
47
|
+
# data in "safe-cache":
|
|
48
|
+
# {
|
|
39
49
|
# "users": {
|
|
40
50
|
# "<user-id>": {
|
|
41
|
-
# "
|
|
42
|
-
# "
|
|
51
|
+
# "access-token": <str>
|
|
52
|
+
# "refresh-token": <str>
|
|
43
53
|
# "access-expiration": <timestamp>,
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# "refresh-token": <str>
|
|
54
|
+
# "login-expiration": <timestamp>, <-- transient
|
|
55
|
+
# "login-id": <str>, <-- transient
|
|
56
|
+
# "oauth-scope": <str> <-- optional
|
|
48
57
|
# }
|
|
49
58
|
# }
|
|
50
59
|
# }
|
|
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
|
-
}
|
|
60
|
+
_keycloak_registry: dict[str, Any] = {}
|
|
60
61
|
|
|
61
62
|
# dafault logger
|
|
62
63
|
_logger: Logger | None = None
|
|
@@ -66,12 +67,13 @@ def keycloak_setup(flask_app: Flask,
|
|
|
66
67
|
client_id: str = KEYCLOAK_CLIENT_ID,
|
|
67
68
|
client_secret: str = KEYCLOAK_CLIENT_SECRET,
|
|
68
69
|
client_timeout: int = KEYCLOAK_CLIENT_TIMEOUT,
|
|
70
|
+
public_key_lifetime: int = KEYCLOAK_PUBLIC_KEY_LIFETIME,
|
|
69
71
|
realm: str = KEYCLOAK_REALM,
|
|
70
72
|
callback_endpoint: str = KEYCLOAK_ENDPOINT_CALLBACK,
|
|
71
73
|
token_endpoint: str = KEYCLOAK_ENDPOINT_TOKEN,
|
|
72
74
|
login_endpoint: str = KEYCLOAK_ENDPOINT_LOGIN,
|
|
73
75
|
logout_endpoint: str = KEYCLOAK_ENDPOINT_LOGOUT,
|
|
74
|
-
|
|
76
|
+
base_url: str = KEYCLOAK_URL_AUTH_BASE,
|
|
75
77
|
callback_url: str = KEYCLOAK_URL_AUTH_CALLBACK,
|
|
76
78
|
logger: Logger = None) -> None:
|
|
77
79
|
"""
|
|
@@ -83,12 +85,13 @@ def keycloak_setup(flask_app: Flask,
|
|
|
83
85
|
:param client_id: the client's identification with JusBR
|
|
84
86
|
:param client_secret: the client's password with JusBR
|
|
85
87
|
:param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
|
|
86
|
-
:param
|
|
88
|
+
:param public_key_lifetime: how long to use Keycloak's public key, before refreshing it (in seconds)
|
|
89
|
+
:param realm: the Keycloak realm
|
|
87
90
|
:param callback_endpoint: endpoint for the callback from JusBR
|
|
88
91
|
:param token_endpoint: endpoint for retrieving the JusBR authentication token
|
|
89
92
|
:param login_endpoint: endpoint for redirecting user to JusBR login page
|
|
90
93
|
:param logout_endpoint: endpoint for terminating user access to JusBR
|
|
91
|
-
:param
|
|
94
|
+
:param base_url: base URL to request the JusBR services
|
|
92
95
|
:param callback_url: URL for Keycloak to callback on login
|
|
93
96
|
:param logger: optional logger
|
|
94
97
|
"""
|
|
@@ -99,15 +102,16 @@ def keycloak_setup(flask_app: Flask,
|
|
|
99
102
|
_logger = logger
|
|
100
103
|
|
|
101
104
|
# configure the JusBR registry
|
|
102
|
-
_keycloak_registry
|
|
105
|
+
_keycloak_registry = {
|
|
103
106
|
"client-id": client_id,
|
|
104
107
|
"client-secret": client_secret,
|
|
105
108
|
"client-timeout": client_timeout,
|
|
106
|
-
"
|
|
107
|
-
"auth-url": auth_url,
|
|
109
|
+
"base-url": f"{base_url}/realms/{realm}",
|
|
108
110
|
"callback-url": callback_url,
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
+
"key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
|
|
112
|
+
"key-lifetime": public_key_lifetime,
|
|
113
|
+
"safe-cache": FIFOCache(maxsize=1048576)
|
|
114
|
+
}
|
|
111
115
|
|
|
112
116
|
# establish the endpoints
|
|
113
117
|
if token_endpoint:
|
|
@@ -144,28 +148,52 @@ def service_login() -> Response:
|
|
|
144
148
|
"""
|
|
145
149
|
global _keycloak_registry
|
|
146
150
|
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
# log the request
|
|
152
|
+
if _logger:
|
|
153
|
+
_logger.debug(msg=_log_init(request=request))
|
|
154
|
+
|
|
155
|
+
# obtain the redirect URL
|
|
156
|
+
auth_url: str = _service_login(registry=_keycloak_registry,
|
|
157
|
+
args=request.args,
|
|
158
|
+
logger=_logger)
|
|
159
|
+
# redirect the request
|
|
160
|
+
result: Response = redirect(location=auth_url)
|
|
161
|
+
|
|
162
|
+
# log the response
|
|
163
|
+
if _logger:
|
|
164
|
+
_logger.debug(msg=f"Response {result}")
|
|
165
|
+
|
|
166
|
+
return result
|
|
151
167
|
|
|
152
168
|
|
|
153
169
|
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
|
|
154
170
|
# methods=["GET"])
|
|
155
171
|
def service_logout() -> Response:
|
|
156
172
|
"""
|
|
157
|
-
Entry point for the
|
|
173
|
+
Entry point for the Keycloak logout service.
|
|
158
174
|
|
|
159
|
-
Remove all data associating the user with
|
|
175
|
+
Remove all data associating the user with Keycloak from the registry.
|
|
160
176
|
|
|
161
177
|
:return: response *OK*
|
|
162
178
|
"""
|
|
163
179
|
global _keycloak_registry
|
|
164
180
|
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
# log the request
|
|
182
|
+
if _logger:
|
|
183
|
+
_logger.debug(msg=_log_init(request=request))
|
|
184
|
+
|
|
185
|
+
# logout the user
|
|
186
|
+
_service_logout(registry=_keycloak_registry,
|
|
187
|
+
args=request.args,
|
|
188
|
+
logger=_logger)
|
|
189
|
+
|
|
190
|
+
result: Response = Response(status=200)
|
|
191
|
+
|
|
192
|
+
# log the response
|
|
193
|
+
if _logger:
|
|
194
|
+
_logger.debug(msg=f"Response {result}")
|
|
195
|
+
|
|
196
|
+
return result
|
|
169
197
|
|
|
170
198
|
|
|
171
199
|
# @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
|
|
@@ -177,7 +205,31 @@ def service_callback() -> Response:
|
|
|
177
205
|
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
178
206
|
"""
|
|
179
207
|
global _keycloak_registry
|
|
180
|
-
|
|
208
|
+
|
|
209
|
+
# log the request
|
|
210
|
+
if _logger:
|
|
211
|
+
_logger.debug(msg=_log_init(request=request))
|
|
212
|
+
|
|
213
|
+
# process the callback operation
|
|
214
|
+
errors: list[str] = []
|
|
215
|
+
token_data: tuple[str, str] = _service_callback(registry=_keycloak_registry,
|
|
216
|
+
args=request.args,
|
|
217
|
+
errors=errors,
|
|
218
|
+
logger=_logger)
|
|
219
|
+
result: Response
|
|
220
|
+
if errors:
|
|
221
|
+
result = jsonify({"errors": "; ".join(errors)})
|
|
222
|
+
result.status_code = 400
|
|
223
|
+
else:
|
|
224
|
+
result = jsonify({
|
|
225
|
+
"user_id": token_data[0],
|
|
226
|
+
"access_token": token_data[1]})
|
|
227
|
+
|
|
228
|
+
# log the response
|
|
229
|
+
if _logger:
|
|
230
|
+
_logger.debug(msg=f"Response {result}")
|
|
231
|
+
|
|
232
|
+
return result
|
|
181
233
|
|
|
182
234
|
|
|
183
235
|
# @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
|
|
@@ -188,26 +240,70 @@ def service_token() -> Response:
|
|
|
188
240
|
|
|
189
241
|
:return: the response containing the token, or *UNAUTHORIZED*
|
|
190
242
|
"""
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
243
|
+
global _keycloak_registry
|
|
244
|
+
|
|
245
|
+
# log the request
|
|
246
|
+
if _logger:
|
|
247
|
+
_logger.debug(msg=_log_init(request=request))
|
|
248
|
+
|
|
249
|
+
# retrieve the token
|
|
250
|
+
errors: list[str] = []
|
|
251
|
+
token: str = _service_token(registry=_keycloak_registry,
|
|
252
|
+
args=request.args,
|
|
253
|
+
errors=errors,
|
|
254
|
+
logger=_logger)
|
|
255
|
+
result: Response
|
|
256
|
+
if token:
|
|
257
|
+
result = jsonify({"token": token})
|
|
258
|
+
else:
|
|
259
|
+
result = Response("; ".join(errors))
|
|
260
|
+
result.status_code = 401
|
|
261
|
+
|
|
262
|
+
# log the response
|
|
263
|
+
if _logger:
|
|
264
|
+
_logger.debug(msg=f"Response {result}")
|
|
265
|
+
|
|
266
|
+
return result
|
|
195
267
|
|
|
196
268
|
|
|
197
269
|
def keycloak_get_token(user_id: str,
|
|
198
270
|
errors: list[str] = None,
|
|
199
271
|
logger: Logger = None) -> str:
|
|
200
272
|
"""
|
|
201
|
-
Retrieve
|
|
273
|
+
Retrieve a Keycloak authentication token for *user_id*.
|
|
202
274
|
|
|
203
275
|
:param user_id: the user's identification
|
|
204
|
-
:param errors: incidental
|
|
276
|
+
:param errors: incidental errors
|
|
205
277
|
:param logger: optional logger
|
|
206
|
-
:return: the
|
|
278
|
+
:return: the uthentication tokem
|
|
207
279
|
"""
|
|
208
280
|
global _keycloak_registry
|
|
209
281
|
|
|
210
|
-
#
|
|
211
|
-
|
|
212
|
-
return
|
|
282
|
+
# retrieve the token
|
|
283
|
+
args: dict[str, Any] = {"user-id": user_id}
|
|
284
|
+
return _service_token(registry=_keycloak_registry,
|
|
285
|
+
args=args,
|
|
286
|
+
errors=errors,
|
|
287
|
+
logger=logger)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def keycloak_set_scope(user_id: str,
|
|
291
|
+
scope: str,
|
|
292
|
+
logger: Logger | None) -> None:
|
|
293
|
+
"""
|
|
294
|
+
Set the OAuth2 scope of *user_id* to *scope*.
|
|
295
|
+
|
|
296
|
+
:param user_id: the user's identification
|
|
297
|
+
:param scope: the OAuth2 scope to set to the user
|
|
298
|
+
:param logger: optional logger
|
|
299
|
+
"""
|
|
300
|
+
global _keycloak_registry
|
|
213
301
|
|
|
302
|
+
# retrieve user data
|
|
303
|
+
user_data: dict[str, Any] = _get_user_data(registry=_keycloak_registry,
|
|
304
|
+
user_id=user_id,
|
|
305
|
+
logger=logger)
|
|
306
|
+
# set the OAuth2 scope
|
|
307
|
+
user_data["oauth-scope"] = scope
|
|
308
|
+
if logger:
|
|
309
|
+
logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_iam
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
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
|
|
@@ -13,6 +13,6 @@ Requires-Python: >=3.12
|
|
|
13
13
|
Requires-Dist: cachetools>=6.2.1
|
|
14
14
|
Requires-Dist: flask>=3.1.2
|
|
15
15
|
Requires-Dist: pyjwt>=2.10.1
|
|
16
|
-
Requires-Dist: pypomes-core>=2.8.
|
|
16
|
+
Requires-Dist: pypomes-core>=2.8.1
|
|
17
17
|
Requires-Dist: pypomes-crypto>=0.4.8
|
|
18
18
|
Requires-Dist: requests>=2.32.5
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pypomes_iam/__init__.py,sha256=ieysDaKOQc3B50PvChh8DLDG5R3XgbTzX3bU0ekGoUk,760
|
|
2
|
+
pypomes_iam/common_pomes.py,sha256=bLDaoWM5KLccxsNSyiK5UbXRNBgqsQ7TB0Q4Nc72QoI,16415
|
|
3
|
+
pypomes_iam/jusbr_pomes.py,sha256=zpvSfQwteY7aL5noG7ARlLT9yNadfuCMdCZB89yvgI4,10932
|
|
4
|
+
pypomes_iam/keycloak_pomes.py,sha256=2KfAQb_-p8C7cXeKSqjHwMIh1ThncinM8VOLEB_JEng,11381
|
|
5
|
+
pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
|
|
6
|
+
pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
|
|
7
|
+
pypomes_iam-0.1.9.dist-info/METADATA,sha256=i0F_RcCVWNIfAjKhOlPl2t8-oGk-6bDfPPqURYPvJJo,694
|
|
8
|
+
pypomes_iam-0.1.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
pypomes_iam-0.1.9.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
|
|
10
|
+
pypomes_iam-0.1.9.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
|