pdmv-http-client 2.1.0__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.
- pdmv_http_client-2.1.0.dist-info/METADATA +68 -0
- pdmv_http_client-2.1.0.dist-info/RECORD +26 -0
- pdmv_http_client-2.1.0.dist-info/WHEEL +5 -0
- pdmv_http_client-2.1.0.dist-info/licenses/LICENSE +21 -0
- pdmv_http_client-2.1.0.dist-info/top_level.txt +1 -0
- rest/__init__.py +10 -0
- rest/_version.py +34 -0
- rest/applications/__init__.py +0 -0
- rest/applications/base.py +238 -0
- rest/applications/mcm/__init__.py +0 -0
- rest/applications/mcm/core.py +277 -0
- rest/applications/mcm/invalidate_request.py +399 -0
- rest/applications/mcm/resubmission.py +431 -0
- rest/applications/rereco/core.py +78 -0
- rest/applications/stats/core.py +95 -0
- rest/client/__init__.py +0 -0
- rest/client/auth/__init__.py +0 -0
- rest/client/auth/auth_interface.py +102 -0
- rest/client/auth/handlers/__init__.py +0 -0
- rest/client/auth/handlers/oauth2_tokens.py +307 -0
- rest/client/auth/handlers/session_cookies.py +110 -0
- rest/client/session.py +99 -0
- rest/utils/__init__.py +0 -0
- rest/utils/logger.py +36 -0
- rest/utils/miscellaneous.py +47 -0
- rest/utils/shell.py +57 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main operations to load, save and renew credentials
|
|
3
|
+
for an HTTP client session.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
from requests import Response, Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthInterface(ABC):
|
|
12
|
+
"""
|
|
13
|
+
Defines the main operations to set a credential
|
|
14
|
+
to authenticate and authorize HTTP requests targeting CERN
|
|
15
|
+
web applications.
|
|
16
|
+
|
|
17
|
+
For this context, a credential could be:
|
|
18
|
+
- OAuth2 token: Requested via `Client Credentials` or `Device Authorization` grants.
|
|
19
|
+
- Format: JSON Web Tokens (JWT)
|
|
20
|
+
- HTTP cookie: A cookie file requested using CERN internal packages.
|
|
21
|
+
- Format: Netscape Cookie
|
|
22
|
+
|
|
23
|
+
For more details, check the `handlers` module.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def _load_credential(self):
|
|
28
|
+
"""
|
|
29
|
+
Loads a credential from the given path.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
None: In case it was not possible to load a credential
|
|
33
|
+
from the given path.
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def _request_credential(self):
|
|
39
|
+
"""
|
|
40
|
+
Requests a credential to the CERN Auth service.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
PermissionError: If it is not possible to obtain a credential
|
|
44
|
+
because the CERN Auth service refused the request.
|
|
45
|
+
RuntimeError: Wrapper for any other possible error cause, the
|
|
46
|
+
original exception must be chained with this one.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def _save_credential(self) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Saves a valid credential in a file into the provided path.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
OSError: In case it is not possible to store the file
|
|
57
|
+
in the provided path because lack of permissions to write
|
|
58
|
+
in the destination.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def authenticate(self) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Provides an entry point to automatically scan if it is possible
|
|
66
|
+
to set credentials by picking them from default locations
|
|
67
|
+
or requests them to the CERN Auth service. Also, this is useful
|
|
68
|
+
for renewing credentials when they are expired.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def configure(self, session: Session) -> Session:
|
|
74
|
+
"""
|
|
75
|
+
Configures the given `request.Session` instance to set
|
|
76
|
+
the credential to perform authenticated requests.
|
|
77
|
+
"""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def validate_response(cls, response: Response) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Checks that a given response has been redirected to
|
|
84
|
+
the CERN Authentication login server or been rejected by it
|
|
85
|
+
server because of a lack of permissions.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
response: Response to check
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True: If the response was resolved by the requested web server
|
|
92
|
+
and its status code is not 401 nor 403.
|
|
93
|
+
False: If the response code was 401 or 403 or the resolver
|
|
94
|
+
was the CERN Authentication service.
|
|
95
|
+
"""
|
|
96
|
+
# Intercepted and redirected to the authentication page.
|
|
97
|
+
if response.url.startswith(
|
|
98
|
+
"https://auth.cern.ch/auth/realms/cern"
|
|
99
|
+
) or response.status_code in (401, 403):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides a handler
|
|
3
|
+
to authenticate requests using OAuth2 tokens.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from json import JSONDecodeError
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from requests.sessions import Session
|
|
13
|
+
|
|
14
|
+
from rest.client.auth.auth_interface import AuthInterface
|
|
15
|
+
from rest.utils.logger import LoggerFactory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AccessTokenHandler(AuthInterface):
|
|
19
|
+
"""
|
|
20
|
+
Loads an access token from a JSON file and configures
|
|
21
|
+
an HTTP session to make use of it. This implements the API
|
|
22
|
+
access procedure described in:
|
|
23
|
+
|
|
24
|
+
- https://auth.docs.cern.ch/user-documentation/oidc/api-access/
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
_url: Target web application URL.
|
|
28
|
+
_credential: A JSON object including the access token, the refresh token
|
|
29
|
+
and some metadata.
|
|
30
|
+
_credential_path: Path to load the access token from or to persist in.
|
|
31
|
+
_client_id: ID for the client application, registered in the application portal,
|
|
32
|
+
to be use
|
|
33
|
+
-client_secret: Secret for the client application.
|
|
34
|
+
_target_application: Client ID linked to the target web
|
|
35
|
+
application.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
TOKEN_ENDPOINT = "https://auth.cern.ch/auth/realms/cern/api-access/token"
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
url: str,
|
|
43
|
+
credential_path: Path,
|
|
44
|
+
client_id: str,
|
|
45
|
+
client_secret: str,
|
|
46
|
+
target_application: str,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._url = url
|
|
49
|
+
self._credential_path = credential_path
|
|
50
|
+
self._client_id = client_id
|
|
51
|
+
self._client_secret = client_secret
|
|
52
|
+
self._target_application = target_application
|
|
53
|
+
self._credential: dict = {}
|
|
54
|
+
self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
|
|
55
|
+
|
|
56
|
+
def _load_credential(self) -> dict:
|
|
57
|
+
try:
|
|
58
|
+
with open(file=self._credential_path, encoding="utf-8") as f:
|
|
59
|
+
credential = json.load(f)
|
|
60
|
+
return credential
|
|
61
|
+
except (OSError, JSONDecodeError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
def _request_credential(self) -> dict:
|
|
65
|
+
access_token = requests.post(
|
|
66
|
+
url=AccessTokenHandler.TOKEN_ENDPOINT,
|
|
67
|
+
data={
|
|
68
|
+
"grant_type": "client_credentials",
|
|
69
|
+
"client_id": self._client_id,
|
|
70
|
+
"client_secret": self._client_secret,
|
|
71
|
+
"audience": self._target_application,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if access_token.status_code != 200:
|
|
76
|
+
msg = (
|
|
77
|
+
f"Error requesting access token ({access_token.status_code}): "
|
|
78
|
+
f"{json.dumps(access_token.json(), indent=4)}"
|
|
79
|
+
)
|
|
80
|
+
raise PermissionError(msg)
|
|
81
|
+
|
|
82
|
+
return access_token.json()
|
|
83
|
+
|
|
84
|
+
def _save_credential(self) -> None:
|
|
85
|
+
with open(file=self._credential_path, mode="w", encoding="utf-8") as f:
|
|
86
|
+
json.dump(obj=self._credential, fp=f, indent=4)
|
|
87
|
+
|
|
88
|
+
os.chmod(path=self._credential_path, mode=0o600)
|
|
89
|
+
|
|
90
|
+
def authenticate(self) -> None:
|
|
91
|
+
loaded_access_token = self._load_credential()
|
|
92
|
+
if self._validate(access_token=loaded_access_token):
|
|
93
|
+
self._credential = loaded_access_token
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Access token is not valid anymore, request another one
|
|
97
|
+
self._logger.debug("Access token is not valid, requesting a new one")
|
|
98
|
+
new_access_token = self._request_credential()
|
|
99
|
+
if self._validate(access_token=new_access_token):
|
|
100
|
+
self._credential = new_access_token
|
|
101
|
+
self._save_credential()
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
def configure(self, session: Session) -> Session:
|
|
105
|
+
if not self._credential:
|
|
106
|
+
self.authenticate()
|
|
107
|
+
|
|
108
|
+
raw_access_token = self._credential.get("access_token", "")
|
|
109
|
+
session.headers.update({"Authorization": f"Bearer {raw_access_token}"})
|
|
110
|
+
return session
|
|
111
|
+
|
|
112
|
+
def _validate(self, access_token: dict) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Checks if the provided access token is valid to consume a resource in
|
|
115
|
+
the target web application.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
access_token: OAuth2 access token retrieve from
|
|
119
|
+
CERN Authentication service.
|
|
120
|
+
"""
|
|
121
|
+
raw_access_token = access_token.get("access_token", "")
|
|
122
|
+
test_response = requests.get(
|
|
123
|
+
self._url, headers={"Authorization": f"Bearer {raw_access_token}"}
|
|
124
|
+
)
|
|
125
|
+
return self.validate_response(test_response)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class IDTokenHandler(AuthInterface):
|
|
129
|
+
"""
|
|
130
|
+
Loads an ID token from a file or requests a new one
|
|
131
|
+
via Device Code Authorization Grant or refresh tokens (in case it is
|
|
132
|
+
provided in the JSON file). This implements the process described at:
|
|
133
|
+
|
|
134
|
+
- https://auth.docs.cern.ch/user-documentation/oidc/device-code/
|
|
135
|
+
|
|
136
|
+
Attributes:
|
|
137
|
+
_url: Target web application URL.
|
|
138
|
+
_credential: A JSON object including the access token, the refresh token
|
|
139
|
+
and some metadata.
|
|
140
|
+
_credential_path: Path to load the access token from or to persist in.
|
|
141
|
+
_client_id: ID for the client application, registered in the application portal,
|
|
142
|
+
to use. This application MUST be configured as a public client, so that no
|
|
143
|
+
client secret is required.
|
|
144
|
+
_target_application: Client ID linked to the target web
|
|
145
|
+
application.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
TOKEN_ENDPOINT = (
|
|
149
|
+
"https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/token"
|
|
150
|
+
)
|
|
151
|
+
DEVICE_ENDPOINT = (
|
|
152
|
+
"https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/auth/device"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
url: str,
|
|
158
|
+
credential_path: Path,
|
|
159
|
+
target_application: str,
|
|
160
|
+
) -> None:
|
|
161
|
+
self._url = url
|
|
162
|
+
self._credential_path = credential_path
|
|
163
|
+
self._target_application = target_application
|
|
164
|
+
self._credential: dict = {}
|
|
165
|
+
self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
|
|
166
|
+
|
|
167
|
+
def _load_credential(self) -> dict:
|
|
168
|
+
try:
|
|
169
|
+
with open(file=self._credential_path, encoding="utf-8") as f:
|
|
170
|
+
credential = json.load(f)
|
|
171
|
+
return credential
|
|
172
|
+
except (OSError, JSONDecodeError):
|
|
173
|
+
return {}
|
|
174
|
+
|
|
175
|
+
def _refresh_token(self) -> dict:
|
|
176
|
+
"""
|
|
177
|
+
Renews the current ID token by requesting a new one
|
|
178
|
+
using the available refresh token.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
dict: ID token.
|
|
182
|
+
"""
|
|
183
|
+
refresh_request = requests.post(
|
|
184
|
+
url=IDTokenHandler.TOKEN_ENDPOINT,
|
|
185
|
+
data={
|
|
186
|
+
"grant_type": "refresh_token",
|
|
187
|
+
"client_id": self._target_application,
|
|
188
|
+
"refresh_token": self._credential.get("refresh_token", ""),
|
|
189
|
+
},
|
|
190
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
191
|
+
)
|
|
192
|
+
details = refresh_request.json()
|
|
193
|
+
if refresh_request.status_code != 200:
|
|
194
|
+
self._logger.debug(
|
|
195
|
+
"Unable to refresh ID token (HTTP code: %s), details: %s",
|
|
196
|
+
refresh_request.status_code,
|
|
197
|
+
json.dumps(refresh_request.json(), indent=4),
|
|
198
|
+
)
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
id_token = details
|
|
202
|
+
return id_token
|
|
203
|
+
|
|
204
|
+
def _request_new_id_token(self) -> dict:
|
|
205
|
+
"""
|
|
206
|
+
Request a new ID token to the authentication service
|
|
207
|
+
using a Device Code Authorization Grant. This requires
|
|
208
|
+
human interaction to complete the flow
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
dict: ID token.
|
|
212
|
+
"""
|
|
213
|
+
# Request a device code pre-authentication.
|
|
214
|
+
device_code = requests.post(
|
|
215
|
+
url=IDTokenHandler.DEVICE_ENDPOINT,
|
|
216
|
+
data={"client_id": self._target_application},
|
|
217
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
218
|
+
)
|
|
219
|
+
self._logger.info(
|
|
220
|
+
"Go to: %s\n", device_code.json()["verification_uri_complete"]
|
|
221
|
+
)
|
|
222
|
+
input("Press Enter once you have authenticated...")
|
|
223
|
+
|
|
224
|
+
code_details: dict = device_code.json()
|
|
225
|
+
if device_code.status_code == 401:
|
|
226
|
+
msg = (
|
|
227
|
+
f"Make sure the application ({self._target_application}) is configured as a public client"
|
|
228
|
+
f"Error: {json.dumps(code_details, indent=4)}"
|
|
229
|
+
)
|
|
230
|
+
raise PermissionError(msg)
|
|
231
|
+
|
|
232
|
+
# Request the ID token
|
|
233
|
+
device_completion = requests.post(
|
|
234
|
+
url=IDTokenHandler.TOKEN_ENDPOINT,
|
|
235
|
+
data={
|
|
236
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
237
|
+
"device_code": code_details["device_code"],
|
|
238
|
+
"client_id": self._target_application,
|
|
239
|
+
},
|
|
240
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
completion_details = device_completion.json()
|
|
244
|
+
if device_completion.status_code != 200:
|
|
245
|
+
msg = (
|
|
246
|
+
f"Unable to request an ID token\n"
|
|
247
|
+
f"Error: {json.dumps(completion_details, indent=4)}"
|
|
248
|
+
)
|
|
249
|
+
raise PermissionError(msg)
|
|
250
|
+
|
|
251
|
+
id_token = completion_details
|
|
252
|
+
return id_token
|
|
253
|
+
|
|
254
|
+
def _request_credential(self) -> dict:
|
|
255
|
+
# Attempt to refresh the token
|
|
256
|
+
id_token = self._refresh_token()
|
|
257
|
+
if id_token:
|
|
258
|
+
return id_token
|
|
259
|
+
|
|
260
|
+
# Request a new ID token
|
|
261
|
+
self._logger.debug(
|
|
262
|
+
"Requesting new ID token, asking the user to manually complete the flow"
|
|
263
|
+
)
|
|
264
|
+
return self._request_new_id_token()
|
|
265
|
+
|
|
266
|
+
def _save_credential(self) -> None:
|
|
267
|
+
with open(file=self._credential_path, mode="w", encoding="utf-8") as f:
|
|
268
|
+
json.dump(obj=self._credential, fp=f, indent=4)
|
|
269
|
+
|
|
270
|
+
os.chmod(path=self._credential_path, mode=0o600)
|
|
271
|
+
|
|
272
|
+
def authenticate(self) -> None:
|
|
273
|
+
loaded_id_token = self._load_credential()
|
|
274
|
+
if self._validate(id_token=loaded_id_token):
|
|
275
|
+
self._credential = loaded_id_token
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# ID token is not valid anymore, request another one
|
|
279
|
+
self._logger.debug("ID token is not valid, requesting a new one")
|
|
280
|
+
new_id_token = self._request_credential()
|
|
281
|
+
if self._validate(id_token=new_id_token):
|
|
282
|
+
self._credential = new_id_token
|
|
283
|
+
self._save_credential()
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
def configure(self, session: Session) -> Session:
|
|
287
|
+
if not self._credential:
|
|
288
|
+
self.authenticate()
|
|
289
|
+
|
|
290
|
+
raw_access_token = self._credential.get("access_token", "")
|
|
291
|
+
session.headers.update({"Authorization": f"Bearer {raw_access_token}"})
|
|
292
|
+
return session
|
|
293
|
+
|
|
294
|
+
def _validate(self, id_token: dict) -> bool:
|
|
295
|
+
"""
|
|
296
|
+
Checks if the provided ID token is valid to consume a resource in
|
|
297
|
+
the target web application.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
id_token: OIDC ID token retrieve from
|
|
301
|
+
CERN Authentication service.
|
|
302
|
+
"""
|
|
303
|
+
raw_access_token = id_token.get("access_token", "")
|
|
304
|
+
test_response = requests.get(
|
|
305
|
+
self._url, headers={"Authorization": f"Bearer {raw_access_token}"}
|
|
306
|
+
)
|
|
307
|
+
return self.validate_response(test_response)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides a handler
|
|
3
|
+
to load a cookie from the file system.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from http.cookiejar import LoadError, MozillaCookieJar
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from requests.sessions import Session
|
|
13
|
+
|
|
14
|
+
from rest.client.auth.auth_interface import AuthInterface
|
|
15
|
+
from rest.utils.logger import LoggerFactory
|
|
16
|
+
from rest.utils.shell import run_command
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionCookieHandler(AuthInterface):
|
|
20
|
+
"""
|
|
21
|
+
Loads a Netscape cookie from a file and configures
|
|
22
|
+
an HTTP session to make use of it.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
_url: Target web application URL.
|
|
26
|
+
_credential: Session cookie loaded from a Netscape file.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, url: str, credential_path: Path):
|
|
30
|
+
self._url = url
|
|
31
|
+
self._credential_path = credential_path
|
|
32
|
+
self._credential: Union[MozillaCookieJar, None] = None
|
|
33
|
+
self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
|
|
34
|
+
|
|
35
|
+
def _load_credential(self) -> Union[MozillaCookieJar, None]:
|
|
36
|
+
try:
|
|
37
|
+
cookie = MozillaCookieJar(self._credential_path)
|
|
38
|
+
cookie.load()
|
|
39
|
+
return cookie
|
|
40
|
+
except (LoadError, OSError):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def _request_credential(self) -> MozillaCookieJar:
|
|
44
|
+
# Remove the cookie file in case there's available
|
|
45
|
+
self._credential_path.unlink(missing_ok=True)
|
|
46
|
+
|
|
47
|
+
# Request a session cookie using CERN internal packages.
|
|
48
|
+
command = (
|
|
49
|
+
f"auth-get-sso-cookie -u '{self._url}' -o '{str(self._credential_path)}'"
|
|
50
|
+
)
|
|
51
|
+
_, stderr, exit_code = run_command(command=command)
|
|
52
|
+
|
|
53
|
+
# Most likely the package is not available or
|
|
54
|
+
# there's no valid Kerberos ticket.
|
|
55
|
+
if exit_code != 0 or stderr:
|
|
56
|
+
msg = (
|
|
57
|
+
f"Session cookie requested via: '{command}'\n"
|
|
58
|
+
f"Standard error: {stderr}\n"
|
|
59
|
+
)
|
|
60
|
+
raise RuntimeError(msg)
|
|
61
|
+
|
|
62
|
+
# Load the cookie
|
|
63
|
+
return self._load_credential()
|
|
64
|
+
|
|
65
|
+
def _save_credential(self) -> None:
|
|
66
|
+
# Credential is already stored, just change its permissions.
|
|
67
|
+
os.chmod(path=self._credential_path, mode=0o600)
|
|
68
|
+
|
|
69
|
+
def authenticate(self) -> None:
|
|
70
|
+
loaded_cookie = self._load_credential()
|
|
71
|
+
if self._validate(cookie=loaded_cookie):
|
|
72
|
+
self._credential = loaded_cookie
|
|
73
|
+
self._save_credential()
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# The credential is not valid anymore, renew it.
|
|
77
|
+
self._logger.debug("Session cookie is not valid, requesting a new one")
|
|
78
|
+
renewed_cookie = self._request_credential()
|
|
79
|
+
if self._validate(cookie=renewed_cookie):
|
|
80
|
+
self._credential = renewed_cookie
|
|
81
|
+
self._save_credential()
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# It is not possible to authenticate a request
|
|
85
|
+
# using session cookies.
|
|
86
|
+
msg = (
|
|
87
|
+
f"Unable to consume a resource in the target web application ({self._url}) "
|
|
88
|
+
"using session cookies.\n"
|
|
89
|
+
"Remember this method only works if 2FA is not enabled.\n"
|
|
90
|
+
"Please use another authentication strategy instead."
|
|
91
|
+
)
|
|
92
|
+
raise PermissionError(msg)
|
|
93
|
+
|
|
94
|
+
def configure(self, session: Session) -> Session:
|
|
95
|
+
if not self._credential:
|
|
96
|
+
self.authenticate()
|
|
97
|
+
|
|
98
|
+
session.cookies.update(self._credential)
|
|
99
|
+
return session
|
|
100
|
+
|
|
101
|
+
def _validate(self, cookie: MozillaCookieJar) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Checks if the provided cookie is valid to consume a resource in
|
|
104
|
+
the target web application.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
cookie: Cookie to check.
|
|
108
|
+
"""
|
|
109
|
+
test_response = requests.get(self._url, cookies=cookie)
|
|
110
|
+
return self.validate_response(test_response)
|
rest/client/session.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configures a request HTTP session so that it handles authentication
|
|
3
|
+
to the web service automatically.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from rest.client.auth.auth_interface import AuthInterface
|
|
12
|
+
from rest.client.auth.handlers.oauth2_tokens import AccessTokenHandler, IDTokenHandler
|
|
13
|
+
from rest.client.auth.handlers.session_cookies import SessionCookieHandler
|
|
14
|
+
from rest.utils.logger import LoggerFactory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticatedSession(requests.Session):
|
|
18
|
+
def __init__(self, handler: AuthInterface) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self._handler = handler
|
|
21
|
+
self._max_attempts = 3
|
|
22
|
+
self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
|
|
23
|
+
self._handler.configure(session=self)
|
|
24
|
+
|
|
25
|
+
def request(
|
|
26
|
+
self, method: Union[str, bytes], url: Union[str, bytes], *args, **kwargs
|
|
27
|
+
) -> requests.Response:
|
|
28
|
+
for attempt, _ in enumerate(range(self._max_attempts), start=1):
|
|
29
|
+
# Restart the session and send the request
|
|
30
|
+
with self:
|
|
31
|
+
response = super().request(method, url, *args, **kwargs)
|
|
32
|
+
|
|
33
|
+
if self._handler.validate_response(response):
|
|
34
|
+
return response
|
|
35
|
+
else:
|
|
36
|
+
# Re-authenticate and return the response.
|
|
37
|
+
self._logger.debug(
|
|
38
|
+
"(%s/%s) Credentials expired, renewing them and retrying the request",
|
|
39
|
+
attempt,
|
|
40
|
+
self._max_attempts,
|
|
41
|
+
)
|
|
42
|
+
self._handler.authenticate()
|
|
43
|
+
self._handler.configure(session=self)
|
|
44
|
+
|
|
45
|
+
self._logger.warning(
|
|
46
|
+
"Unable to renew credentials for (%s) after %s attempts: HTTP code %s",
|
|
47
|
+
response.url,
|
|
48
|
+
self._max_attempts,
|
|
49
|
+
response.status_code,
|
|
50
|
+
)
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SessionFactory:
|
|
55
|
+
"""
|
|
56
|
+
Provides a pre-configured `request.Session` with the
|
|
57
|
+
required authentication method.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def configure_by_session_cookie(
|
|
62
|
+
cls, url: str, credential_path: Path
|
|
63
|
+
) -> requests.Session:
|
|
64
|
+
session_cookie_handler = SessionCookieHandler(
|
|
65
|
+
url=url, credential_path=credential_path
|
|
66
|
+
)
|
|
67
|
+
return AuthenticatedSession(handler=session_cookie_handler)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def configure_by_access_token(
|
|
71
|
+
cls,
|
|
72
|
+
url: str,
|
|
73
|
+
credential_path: Path,
|
|
74
|
+
client_id: str,
|
|
75
|
+
client_secret: str,
|
|
76
|
+
target_application: str,
|
|
77
|
+
) -> requests.Session:
|
|
78
|
+
access_token_handler = AccessTokenHandler(
|
|
79
|
+
url=url,
|
|
80
|
+
credential_path=credential_path,
|
|
81
|
+
client_id=client_id,
|
|
82
|
+
client_secret=client_secret,
|
|
83
|
+
target_application=target_application,
|
|
84
|
+
)
|
|
85
|
+
return AuthenticatedSession(handler=access_token_handler)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def configure_by_id_token(
|
|
89
|
+
cls,
|
|
90
|
+
url: str,
|
|
91
|
+
credential_path: Path,
|
|
92
|
+
target_application: str,
|
|
93
|
+
) -> requests.Session:
|
|
94
|
+
id_token_handler = IDTokenHandler(
|
|
95
|
+
url=url,
|
|
96
|
+
credential_path=credential_path,
|
|
97
|
+
target_application=target_application,
|
|
98
|
+
)
|
|
99
|
+
return AuthenticatedSession(handler=id_token_handler)
|
rest/utils/__init__.py
ADDED
|
File without changes
|
rest/utils/logger.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module configures the library logger,
|
|
3
|
+
its message format and its level.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoggerFactory:
|
|
11
|
+
_instances: dict[str, logging.Logger] = {}
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def _create_logger(cls, name: str) -> logging.Logger:
|
|
15
|
+
logger = logging.getLogger(name=name)
|
|
16
|
+
if not logger.hasHandlers():
|
|
17
|
+
format = "[%(asctime)s][%(levelname)s][%(name)s]: %(message)s"
|
|
18
|
+
date_format = "%Y-%m-%d %H:%M:%S %z"
|
|
19
|
+
handler = logging.StreamHandler(stream=sys.stdout)
|
|
20
|
+
formatter = logging.Formatter(fmt=format, datefmt=date_format)
|
|
21
|
+
|
|
22
|
+
handler.setFormatter(formatter)
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
logger.setLevel(logging.INFO)
|
|
25
|
+
|
|
26
|
+
return logger
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def getLogger(cls, name: str) -> logging.Logger:
|
|
30
|
+
logger = cls._instances.get(name)
|
|
31
|
+
if logger:
|
|
32
|
+
return logger
|
|
33
|
+
|
|
34
|
+
logger = cls._create_logger(name)
|
|
35
|
+
cls._instances[name] = logger
|
|
36
|
+
return logger
|