pypomes-iam 0.3.0__py3-none-any.whl → 0.3.2__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/iam_common.py +241 -302
- pypomes_iam/iam_pomes.py +279 -169
- pypomes_iam/iam_services.py +243 -0
- pypomes_iam/jusbr_pomes.py +20 -14
- pypomes_iam/keycloak_pomes.py +38 -21
- pypomes_iam/token_pomes.py +19 -4
- {pypomes_iam-0.3.0.dist-info → pypomes_iam-0.3.2.dist-info}/METADATA +1 -1
- pypomes_iam-0.3.2.dist-info/RECORD +12 -0
- pypomes_iam-0.3.0.dist-info/RECORD +0 -11
- {pypomes_iam-0.3.0.dist-info → pypomes_iam-0.3.2.dist-info}/WHEEL +0 -0
- {pypomes_iam-0.3.0.dist-info → pypomes_iam-0.3.2.dist-info}/licenses/LICENSE +0 -0
pypomes_iam/iam_pomes.py
CHANGED
|
@@ -1,216 +1,326 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import secrets
|
|
2
|
+
import string
|
|
3
|
+
import sys
|
|
4
|
+
from cachetools import Cache
|
|
5
|
+
from datetime import datetime
|
|
4
6
|
from logging import Logger
|
|
7
|
+
from pypomes_core import TZ_LOCAL
|
|
5
8
|
from typing import Any
|
|
6
9
|
|
|
7
10
|
from .iam_common import (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
IamServer,
|
|
12
|
+
_register_logger, _post_for_token,
|
|
13
|
+
_get_iam_cache, _get_iam_registry,
|
|
14
|
+
_get_login_timeout, _get_user_data, _get_public_key
|
|
11
15
|
)
|
|
12
16
|
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
# methods=["GET"])
|
|
16
|
-
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
|
|
17
|
-
# methods=["GET"])
|
|
18
|
-
def service_login() -> Response:
|
|
18
|
+
def register_logger(logger: Logger) -> None:
|
|
19
19
|
"""
|
|
20
|
-
|
|
20
|
+
Register the logger for IAM operations.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
:return: the response from the redirect operation
|
|
22
|
+
:param logger: the logger to be registered
|
|
25
23
|
"""
|
|
26
|
-
|
|
27
|
-
registry: dict[str, Any] = __get_iam_registry(endpoint=request.endpoint)
|
|
28
|
-
logger: Logger = registry["logger"]
|
|
29
|
-
|
|
30
|
-
# log the request
|
|
31
|
-
if logger:
|
|
32
|
-
logger.debug(msg=_log_init(request=request))
|
|
24
|
+
_register_logger(logger=logger)
|
|
33
25
|
|
|
34
|
-
# obtain the login URL
|
|
35
|
-
login_data: dict[str, str] = _service_login(registry=registry,
|
|
36
|
-
args=request.args,
|
|
37
|
-
logger=logger)
|
|
38
|
-
result = jsonify(login_data)
|
|
39
26
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
27
|
+
def user_login(iam_server: IamServer,
|
|
28
|
+
args: dict[str, Any],
|
|
29
|
+
errors: list[str] = None,
|
|
30
|
+
logger: Logger = None) -> dict[str, str]:
|
|
31
|
+
"""
|
|
32
|
+
Build the callback URL for redirecting the request to *iam_server*'s authentication page.
|
|
43
33
|
|
|
34
|
+
:param iam_server: the reference registered *IAM* server
|
|
35
|
+
:param args: the arguments passed when requesting the service
|
|
36
|
+
:param errors: incidental error messages
|
|
37
|
+
:param logger: optional logger
|
|
38
|
+
:return: the callback URL, with the appropriate parameters, of *None* if error
|
|
39
|
+
"""
|
|
40
|
+
# initialize the return variable
|
|
41
|
+
result: dict[str, str] | None = None
|
|
42
|
+
|
|
43
|
+
# obtain the optional user's identification
|
|
44
|
+
user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
|
|
45
|
+
|
|
46
|
+
# build the user data
|
|
47
|
+
# ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
|
|
48
|
+
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
49
|
+
user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
|
|
50
|
+
user_id=oauth_state,
|
|
51
|
+
errors=errors,
|
|
52
|
+
logger=logger)
|
|
53
|
+
if user_data:
|
|
54
|
+
user_data["login-id"] = user_id
|
|
55
|
+
timeout: int = _get_login_timeout(iam_server=iam_server,
|
|
56
|
+
errors=errors,
|
|
57
|
+
logger=logger)
|
|
58
|
+
if not errors:
|
|
59
|
+
user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
|
|
60
|
+
redirect_uri: str = args.get("redirect-uri")
|
|
61
|
+
|
|
62
|
+
# build the login url
|
|
63
|
+
registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
|
|
64
|
+
errors=errors,
|
|
65
|
+
logger=logger)
|
|
66
|
+
if registry:
|
|
67
|
+
registry["redirect-uri"] = redirect_uri
|
|
68
|
+
result = {"login-url": (f"{registry["base-url"]}/protocol/openid-connect/auth"
|
|
69
|
+
f"?response_type=code&scope=openid"
|
|
70
|
+
f"&client_id={registry["client-id"]}"
|
|
71
|
+
f"&redirect_uri={redirect_uri}"
|
|
72
|
+
f"&state={oauth_state}")}
|
|
44
73
|
return result
|
|
45
74
|
|
|
46
75
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def service_logout() -> Response:
|
|
76
|
+
def user_logout(iam_server: IamServer,
|
|
77
|
+
args: dict[str, Any],
|
|
78
|
+
errors: list[str] = None,
|
|
79
|
+
logger: Logger = None) -> None:
|
|
52
80
|
"""
|
|
53
|
-
|
|
81
|
+
Logout the user, by removing all data associating it from *iam_server*'s registry.
|
|
54
82
|
|
|
55
|
-
|
|
83
|
+
The user is identified by the attribute *user-id*, *user_id*, or "login", provided in *args*.
|
|
84
|
+
If unsuccessful, this operation fails silently, unless an error has ocurred.
|
|
56
85
|
|
|
57
|
-
:
|
|
86
|
+
:param iam_server: the reference registered *IAM* server
|
|
87
|
+
:param args: the arguments passed when requesting the service
|
|
88
|
+
:param errors: incidental error messages
|
|
89
|
+
:param logger: optional logger
|
|
58
90
|
"""
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# @flask_app.route(rule=<callback_endpoint>, # JUSBR_CALLBACK_ENDPOINT: /iam/jusbr:callback
|
|
82
|
-
# methods=["GET", "POST"])
|
|
83
|
-
# @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
|
|
84
|
-
# methods=["POST"])
|
|
85
|
-
def service_callback() -> Response:
|
|
91
|
+
# obtain the user's identification
|
|
92
|
+
user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
|
|
93
|
+
|
|
94
|
+
if user_id:
|
|
95
|
+
# retrieve the IAM server's cache storage
|
|
96
|
+
cache: Cache = _get_iam_cache(iam_server=iam_server,
|
|
97
|
+
errors=errors,
|
|
98
|
+
logger=logger)
|
|
99
|
+
if cache:
|
|
100
|
+
users: dict[str, dict[str, Any]] = cache.get("users") or {}
|
|
101
|
+
if user_id in users:
|
|
102
|
+
users.pop(user_id)
|
|
103
|
+
if logger:
|
|
104
|
+
logger.debug(msg=f"User '{user_id}' removed from {iam_server}'s registry")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def user_token(iam_server: IamServer,
|
|
108
|
+
args: dict[str, Any],
|
|
109
|
+
errors: list[str] = None,
|
|
110
|
+
logger: Logger = None) -> str:
|
|
86
111
|
"""
|
|
87
|
-
|
|
112
|
+
Retrieve the authentication token for the user, from *iam_server*.
|
|
88
113
|
|
|
89
|
-
|
|
90
|
-
JusBR login page, forwarding the data received.
|
|
114
|
+
The user is identified by the attribute *user-id*, *user_id*, or "login", provided in *args*.
|
|
91
115
|
|
|
92
|
-
:
|
|
116
|
+
:param iam_server: the reference registered *IAM* server
|
|
117
|
+
:param args: the arguments passed when requesting the service
|
|
118
|
+
:param errors: incidental error messages
|
|
119
|
+
:param logger: optional logger
|
|
120
|
+
:return: the token for *user_id*, or *None* if error
|
|
93
121
|
"""
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
122
|
+
# initialize the return variable
|
|
123
|
+
result: str | None = None
|
|
124
|
+
|
|
125
|
+
# obtain the user's identification
|
|
126
|
+
user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
|
|
127
|
+
|
|
128
|
+
err_msg: str | None = None
|
|
129
|
+
if user_id:
|
|
130
|
+
user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
|
|
131
|
+
user_id=user_id,
|
|
132
|
+
errors=errors,
|
|
133
|
+
logger=logger)
|
|
134
|
+
token: str = user_data["access-token"] if user_data else None
|
|
135
|
+
if token:
|
|
136
|
+
access_expiration: int = user_data.get("access-expiration")
|
|
137
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
138
|
+
if now < access_expiration:
|
|
139
|
+
result = token
|
|
140
|
+
else:
|
|
141
|
+
# access token has expired
|
|
142
|
+
refresh_token: str = user_data["refresh-token"]
|
|
143
|
+
if refresh_token:
|
|
144
|
+
refresh_expiration = user_data["refresh-expiration"]
|
|
145
|
+
if now < refresh_expiration:
|
|
146
|
+
body_data: dict[str, str] = {
|
|
147
|
+
"grant_type": "refresh_token",
|
|
148
|
+
"refresh_token": refresh_token
|
|
149
|
+
}
|
|
150
|
+
token_data: dict[str, Any] = _post_for_token(iam_server=iam_server,
|
|
151
|
+
body_data=body_data,
|
|
152
|
+
errors=errors,
|
|
153
|
+
logger=logger)
|
|
154
|
+
if token_data:
|
|
155
|
+
result = token_data.get("access_token")
|
|
156
|
+
user_data["access-token"] = result
|
|
157
|
+
# keep current refresh token if a new one is not provided
|
|
158
|
+
user_data["refresh-token"] = (token_data.get("refresh_token") or
|
|
159
|
+
body_data.get("refresh_token"))
|
|
160
|
+
user_data["access-expiration"] = now + token_data.get("expires_in")
|
|
161
|
+
refresh_expiration: int = user_data.get("refresh_expires_in")
|
|
162
|
+
user_data["refresh-expiration"] = (now + refresh_expiration) \
|
|
163
|
+
if refresh_expiration else sys.maxsize
|
|
164
|
+
else:
|
|
165
|
+
# refresh token is no longer valid
|
|
166
|
+
user_data["refresh-token"] = None
|
|
167
|
+
else:
|
|
168
|
+
# refresh token has expired
|
|
169
|
+
err_msg = "Access and refresh tokens expired"
|
|
170
|
+
if logger:
|
|
171
|
+
logger.error(msg=err_msg)
|
|
172
|
+
else:
|
|
173
|
+
err_msg = "Access token expired, no refresh token available"
|
|
174
|
+
if logger:
|
|
175
|
+
logger.error(msg=err_msg)
|
|
137
176
|
else:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
err_msg += f", content '{response.content}'"
|
|
142
|
-
errors.append(err_msg)
|
|
143
|
-
|
|
144
|
-
result: Response
|
|
145
|
-
if errors:
|
|
146
|
-
result = jsonify({"errors": "; ".join(errors)})
|
|
147
|
-
result.status_code = 400
|
|
148
|
-
if logger:
|
|
149
|
-
logger.error(msg=json.dumps(obj=result))
|
|
177
|
+
err_msg = f"User '{user_id}' not authenticated"
|
|
178
|
+
if logger:
|
|
179
|
+
logger.error(msg=err_msg)
|
|
150
180
|
else:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
181
|
+
err_msg = "User identification not provided"
|
|
182
|
+
if logger:
|
|
183
|
+
logger.error(msg=err_msg)
|
|
154
184
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
logger.debug(msg=f"Response {result}")
|
|
185
|
+
if err_msg and isinstance(errors, list):
|
|
186
|
+
errors.append(err_msg)
|
|
158
187
|
|
|
159
188
|
return result
|
|
160
189
|
|
|
161
190
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def service_token() -> Response:
|
|
191
|
+
def login_callback(iam_server: IamServer,
|
|
192
|
+
args: dict[str, Any],
|
|
193
|
+
errors: list[str] = None,
|
|
194
|
+
logger: Logger = None) -> tuple[str, str] | None:
|
|
167
195
|
"""
|
|
168
|
-
Entry point for
|
|
196
|
+
Entry point for the callback from *iam_server* via the front-end application, on authentication operation.
|
|
169
197
|
|
|
170
|
-
:
|
|
198
|
+
:param iam_server: the reference registered *IAM* server
|
|
199
|
+
:param args: the arguments passed when requesting the service
|
|
200
|
+
:param errors: incidental errors
|
|
201
|
+
:param logger: optional logger
|
|
202
|
+
:return: a tuple cotaining the reference user identification and the token obtained, or *None* if error
|
|
171
203
|
"""
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
# log the request
|
|
177
|
-
if logger:
|
|
178
|
-
logger.debug(msg=_log_init(request=request))
|
|
179
|
-
|
|
180
|
-
# retrieve the token
|
|
181
|
-
errors: list[str] = []
|
|
182
|
-
token: str = _service_token(registry=registry,
|
|
183
|
-
args=request.args,
|
|
184
|
-
errors=errors,
|
|
185
|
-
logger=logger)
|
|
186
|
-
result: Response
|
|
187
|
-
if token:
|
|
188
|
-
result = jsonify({"token": token})
|
|
189
|
-
else:
|
|
190
|
-
result = Response("; ".join(errors))
|
|
191
|
-
result.status_code = 401
|
|
204
|
+
from .token_pomes import token_validate
|
|
205
|
+
|
|
206
|
+
# initialize the return variable
|
|
207
|
+
result: tuple[str, str] | None = None
|
|
192
208
|
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
209
|
+
# retrieve the users authentication data
|
|
210
|
+
registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
|
|
211
|
+
errors=errors,
|
|
212
|
+
logger=logger)
|
|
213
|
+
cache: Cache = registry["cache"] if registry else None
|
|
214
|
+
if cache:
|
|
215
|
+
users: dict[str, dict[str, Any]] = cache.get("users")
|
|
216
|
+
|
|
217
|
+
# validate the OAuth2 state
|
|
218
|
+
oauth_state: str = args.get("state")
|
|
219
|
+
user_data: dict[str, Any] | None = None
|
|
220
|
+
if oauth_state:
|
|
221
|
+
for user, data in users.items():
|
|
222
|
+
if user == oauth_state:
|
|
223
|
+
user_data = data
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
# exchange 'code' for the token
|
|
227
|
+
if user_data:
|
|
228
|
+
expiration: int = user_data["login-expiration"] or sys.maxsize
|
|
229
|
+
if int(datetime.now(tz=TZ_LOCAL).timestamp()) > expiration:
|
|
230
|
+
errors.append("Operation timeout")
|
|
231
|
+
else:
|
|
232
|
+
users.pop(oauth_state)
|
|
233
|
+
code: str = args.get("code")
|
|
234
|
+
body_data: dict[str, Any] = {
|
|
235
|
+
"grant_type": "authorization_code",
|
|
236
|
+
"code": code,
|
|
237
|
+
"redirect_uri": registry["redirect-uri"]
|
|
238
|
+
}
|
|
239
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
240
|
+
token_data: dict[str, Any] = _post_for_token(iam_server=iam_server,
|
|
241
|
+
body_data=body_data,
|
|
242
|
+
errors=errors,
|
|
243
|
+
logger=logger)
|
|
244
|
+
# process the token data
|
|
245
|
+
if token_data:
|
|
246
|
+
token: str = token_data.get("access_token")
|
|
247
|
+
user_data["access-token"] = token
|
|
248
|
+
# keep current refresh token if a new one is not provided
|
|
249
|
+
user_data["refresh-token"] = token_data.get("refresh_token") or body_data.get("refresh_token")
|
|
250
|
+
user_data["access-expiration"] = now + token_data.get("expires_in")
|
|
251
|
+
refresh_exp: int = user_data.get("refresh_expires_in")
|
|
252
|
+
user_data["refresh-expiration"] = (now + refresh_exp) if refresh_exp else sys.maxsize
|
|
253
|
+
public_key: str = _get_public_key(iam_server=iam_server,
|
|
254
|
+
errors=errors,
|
|
255
|
+
logger=logger)
|
|
256
|
+
if public_key:
|
|
257
|
+
recipient_attr = registry["recipient_attr"]
|
|
258
|
+
login_id = user_data.pop("login-id", None)
|
|
259
|
+
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
260
|
+
issuer=registry["base-url"],
|
|
261
|
+
recipient_id=login_id,
|
|
262
|
+
recipient_attr=recipient_attr,
|
|
263
|
+
public_key=public_key,
|
|
264
|
+
errors=errors,
|
|
265
|
+
logger=logger)
|
|
266
|
+
if token_claims:
|
|
267
|
+
token_user: str = token_claims["payload"].get(recipient_attr)
|
|
268
|
+
result = (token_user, token)
|
|
269
|
+
else:
|
|
270
|
+
msg: str = "Unknown state received"
|
|
271
|
+
if logger:
|
|
272
|
+
logger.error(msg=msg)
|
|
273
|
+
if isinstance(errors, list):
|
|
274
|
+
errors.append(msg)
|
|
196
275
|
|
|
197
276
|
return result
|
|
198
277
|
|
|
199
278
|
|
|
200
|
-
def
|
|
279
|
+
def token_exchange(iam_server: IamServer,
|
|
280
|
+
args: dict[str, Any],
|
|
281
|
+
errors: list[str] = None,
|
|
282
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
201
283
|
"""
|
|
202
|
-
|
|
284
|
+
Requst *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
|
|
203
285
|
|
|
204
|
-
:param
|
|
205
|
-
:
|
|
286
|
+
:param iam_server: the reference registered *IAM* server
|
|
287
|
+
:param args: the arguments passed when requesting the service
|
|
288
|
+
:param errors: incidental errors
|
|
289
|
+
:param logger: optional logger
|
|
290
|
+
:return: the data for the new token, or *None* if error
|
|
206
291
|
"""
|
|
207
292
|
# initialize the return variable
|
|
208
293
|
result: dict[str, Any] | None = None
|
|
209
294
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
295
|
+
# obtain the user's identification
|
|
296
|
+
user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
|
|
297
|
+
|
|
298
|
+
# retrieve the token to be exchanges
|
|
299
|
+
token: str = args.get("token")
|
|
300
|
+
|
|
301
|
+
if user_id and token:
|
|
302
|
+
# HAZARD: only 'IAM_KEYCLOAK' supported
|
|
303
|
+
registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
|
|
304
|
+
errors=errors,
|
|
305
|
+
logger=logger)
|
|
306
|
+
if registry:
|
|
307
|
+
body_data: dict[str, str] = {
|
|
308
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
309
|
+
"subject_token": token,
|
|
310
|
+
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
|
311
|
+
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
|
312
|
+
"audience": registry["client-id"],
|
|
313
|
+
"subject_issuer": "oidc"
|
|
314
|
+
}
|
|
315
|
+
result = _post_for_token(iam_server=IamServer.IAM_KEYCLOAK,
|
|
316
|
+
body_data=body_data,
|
|
317
|
+
errors=errors,
|
|
318
|
+
logger=logger)
|
|
319
|
+
else:
|
|
320
|
+
msg: str = "User identification and token must be provided"
|
|
321
|
+
if logger:
|
|
322
|
+
logger.error(msg=msg)
|
|
323
|
+
if isinstance(errors, list):
|
|
324
|
+
errors.append(msg)
|
|
214
325
|
|
|
215
326
|
return result
|
|
216
|
-
|