sso-nebus 0.1.4__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/PKG-INFO +5 -1
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/pyproject.toml +35 -19
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/base.py +69 -52
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/service_client.py +197 -197
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/LICENSE +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/README.md +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/__init__.py +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/exceptions.py +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/exmples/example_admin.py +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/exmples/example_service.py +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/exmples/example_user.py +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/models.py +0 -0
- {sso_nebus-0.1.4 → sso_nebus-0.1.6}/sso_nebus/user_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sso-nebus
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Python клиент для взаимодействия с MS Auth Service API
|
|
5
5
|
License: LICENSE
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,8 +12,12 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Provides-Extra: dev
|
|
15
16
|
Requires-Dist: aiohttp (>=3.13.2,<4.0.0)
|
|
16
17
|
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
18
|
+
Requires-Dist: pytest (>=8.0.0) ; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio (>=0.24.0) ; extra == "dev"
|
|
20
|
+
Requires-Dist: python-dotenv (>=1.0.0) ; extra == "dev"
|
|
17
21
|
Description-Content-Type: text/markdown
|
|
18
22
|
|
|
19
23
|
# SSO Nebus Client
|
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "sso-nebus"
|
|
3
|
-
version = "0.1.
|
|
4
|
-
description = "Python клиент для взаимодействия с MS Auth Service API"
|
|
5
|
-
authors = [
|
|
6
|
-
{name = "Артем Костюченко",email = "kostyuchenko.work@gmail.com"}
|
|
7
|
-
]
|
|
8
|
-
license = "LICENSE"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.12"
|
|
11
|
-
dependencies = [
|
|
12
|
-
"aiohttp (>=3.13.2,<4.0.0)",
|
|
13
|
-
"pydantic (>=2.12.5,<3.0.0)"
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
[
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
[project]
|
|
2
|
+
name = "sso-nebus"
|
|
3
|
+
version = "0.1.6"
|
|
4
|
+
description = "Python клиент для взаимодействия с MS Auth Service API"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Артем Костюченко",email = "kostyuchenko.work@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
license = "LICENSE"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"aiohttp (>=3.13.2,<4.0.0)",
|
|
13
|
+
"pydantic (>=2.12.5,<3.0.0)"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest (>=8.0.0)",
|
|
19
|
+
"pytest-asyncio (>=0.24.0)",
|
|
20
|
+
"python-dotenv (>=1.0.0)",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.poetry.group.dev.dependencies]
|
|
24
|
+
pytest = ">=8.0.0"
|
|
25
|
+
pytest-asyncio = ">=0.24.0"
|
|
26
|
+
python-dotenv = ">=1.0.0"
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
35
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Базовый класс для SSO клиентов"""
|
|
2
2
|
|
|
3
|
-
from typing import Optional, Dict, Any
|
|
3
|
+
from typing import Optional, Dict, Any, List
|
|
4
4
|
from urllib.parse import urljoin
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
|
|
@@ -174,49 +174,54 @@ class BaseClient(ABC):
|
|
|
174
174
|
if form_data:
|
|
175
175
|
headers.pop("Content-Type", None)
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
177
|
+
try:
|
|
178
|
+
async with self.session.request(
|
|
179
|
+
method=method,
|
|
180
|
+
url=url,
|
|
181
|
+
headers=headers,
|
|
182
|
+
json=json_data,
|
|
183
|
+
data=form_data,
|
|
184
|
+
params=params,
|
|
185
|
+
) as response:
|
|
186
|
+
# Если получили 401 и включен авто-рефреш, пытаемся обновить токен
|
|
187
|
+
if (
|
|
188
|
+
response.status == 401
|
|
189
|
+
and self.auto_refresh_token
|
|
190
|
+
and retry_on_401
|
|
191
|
+
and not self._refreshing
|
|
192
|
+
and access_token
|
|
193
|
+
):
|
|
194
|
+
try:
|
|
195
|
+
# Пытаемся обновить токен
|
|
196
|
+
self._refreshing = True
|
|
197
|
+
await self._refresh_token()
|
|
198
|
+
# Получаем новый токен
|
|
199
|
+
new_token = self._get_access_token()
|
|
200
|
+
if new_token and new_token != access_token:
|
|
201
|
+
# Повторяем запрос с новым токеном
|
|
202
|
+
headers = self._get_headers(new_token)
|
|
203
|
+
if form_data:
|
|
204
|
+
headers.pop("Content-Type", None)
|
|
205
|
+
async with self.session.request(
|
|
206
|
+
method=method,
|
|
207
|
+
url=url,
|
|
208
|
+
headers=headers,
|
|
209
|
+
json=json_data,
|
|
210
|
+
data=form_data,
|
|
211
|
+
params=params,
|
|
212
|
+
) as retry_response:
|
|
213
|
+
return await self._handle_response(retry_response)
|
|
214
|
+
except Exception:
|
|
215
|
+
# Если обновление не удалось, пробрасываем оригинальную ошибку
|
|
216
|
+
pass
|
|
217
|
+
finally:
|
|
218
|
+
self._refreshing = False
|
|
219
|
+
|
|
220
|
+
return await self._handle_response(response)
|
|
221
|
+
finally:
|
|
222
|
+
# Закрываем сессию после взаимодействия (если она наша), чтобы избежать "Unclosed client session"
|
|
223
|
+
if self._own_session and self._session and not self._session.closed:
|
|
224
|
+
await self._session.close()
|
|
220
225
|
|
|
221
226
|
async def get(
|
|
222
227
|
self,
|
|
@@ -394,18 +399,17 @@ class BaseClient(ABC):
|
|
|
394
399
|
async def create_scope(
|
|
395
400
|
self,
|
|
396
401
|
name: str,
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
client: str,
|
|
403
|
+
role: str,
|
|
399
404
|
action: str,
|
|
400
405
|
description: Optional[str] = None,
|
|
401
406
|
access_token: Optional[str] = None,
|
|
402
407
|
) -> Dict[str, Any]:
|
|
403
408
|
"""Создать новое разрешение (требует sso.admin.create)"""
|
|
404
409
|
json_data = {
|
|
405
|
-
"
|
|
406
|
-
"
|
|
407
|
-
"
|
|
408
|
-
"action": action,
|
|
410
|
+
"client": client,
|
|
411
|
+
"role": role,
|
|
412
|
+
"action": action
|
|
409
413
|
}
|
|
410
414
|
if description:
|
|
411
415
|
json_data["description"] = description
|
|
@@ -479,16 +483,29 @@ class BaseClient(ABC):
|
|
|
479
483
|
async def assign_scopes_to_client(
|
|
480
484
|
self,
|
|
481
485
|
client_id: str,
|
|
482
|
-
scope_ids:
|
|
486
|
+
scope_ids: List[int],
|
|
483
487
|
access_token: Optional[str] = None,
|
|
484
488
|
) -> Dict[str, Any]:
|
|
485
|
-
"""Назначить разрешения клиенту (требует sso.admin.edit)"""
|
|
489
|
+
"""Назначить разрешения (scopes) клиенту (требует sso.admin.edit)"""
|
|
486
490
|
return await self.post(
|
|
487
491
|
f"admin/clients/{client_id}/scopes",
|
|
488
492
|
access_token=access_token,
|
|
489
493
|
json_data={"scope_ids": scope_ids},
|
|
490
494
|
)
|
|
491
495
|
|
|
496
|
+
async def assign_roles_to_client(
|
|
497
|
+
self,
|
|
498
|
+
client_id: str,
|
|
499
|
+
role_ids: List[int],
|
|
500
|
+
access_token: Optional[str] = None,
|
|
501
|
+
) -> Dict[str, Any]:
|
|
502
|
+
"""Привязать роли к микросервису (клиенту). POST admin/clients/{client_id}/roles, body: role_ids (требует sso.admin.edit)"""
|
|
503
|
+
return await self.post(
|
|
504
|
+
f"admin/clients/{client_id}/roles",
|
|
505
|
+
access_token=access_token,
|
|
506
|
+
json_data={"role_ids": role_ids},
|
|
507
|
+
)
|
|
508
|
+
|
|
492
509
|
async def rotate_client_secret(
|
|
493
510
|
self,
|
|
494
511
|
client_id: str,
|
|
@@ -1,197 +1,197 @@
|
|
|
1
|
-
"""Клиент для микросервисного взаимодействия (Client Credentials)"""
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import json
|
|
5
|
-
from typing import Any, Dict, Optional
|
|
6
|
-
|
|
7
|
-
from .base import BaseClient
|
|
8
|
-
from .models import TokenResponse, UserInfo
|
|
9
|
-
from .exceptions import TokenError
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _decode_jwt_payload(token: str) -> Dict[str, Any]:
|
|
13
|
-
"""
|
|
14
|
-
Декодирует payload JWT без проверки подписи (только чтение полей).
|
|
15
|
-
Токен получен от SSO, используется для извлечения claim service_name.
|
|
16
|
-
"""
|
|
17
|
-
try:
|
|
18
|
-
parts = token.split(".")
|
|
19
|
-
if len(parts) != 3:
|
|
20
|
-
return {}
|
|
21
|
-
payload_b64 = parts[1]
|
|
22
|
-
padding = 4 - len(payload_b64) % 4
|
|
23
|
-
if padding != 4:
|
|
24
|
-
payload_b64 += "=" * padding
|
|
25
|
-
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
26
|
-
return json.loads(payload_bytes.decode("utf-8"))
|
|
27
|
-
except Exception:
|
|
28
|
-
return {}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class ServiceClient(BaseClient):
|
|
32
|
-
"""Клиент для микросервисного взаимодействия с Client Credentials Grant"""
|
|
33
|
-
|
|
34
|
-
def __init__(
|
|
35
|
-
self,
|
|
36
|
-
base_url: str,
|
|
37
|
-
client_id: str,
|
|
38
|
-
client_secret: str,
|
|
39
|
-
api_version: str = "v1",
|
|
40
|
-
timeout: int = 30,
|
|
41
|
-
session=None,
|
|
42
|
-
auto_refresh_token: bool = True,
|
|
43
|
-
default_scope: Optional[str] = None,
|
|
44
|
-
):
|
|
45
|
-
"""
|
|
46
|
-
Инициализация клиента для микросервисов
|
|
47
|
-
|
|
48
|
-
Args:
|
|
49
|
-
base_url: Базовый URL API
|
|
50
|
-
client_id: ID микросервиса
|
|
51
|
-
client_secret: Секрет микросервиса
|
|
52
|
-
api_version: Версия API
|
|
53
|
-
timeout: Таймаут запросов
|
|
54
|
-
session: Опциональная aiohttp сессия
|
|
55
|
-
auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
|
|
56
|
-
default_scope: Scope по умолчанию для автоматического получения токена
|
|
57
|
-
"""
|
|
58
|
-
super().__init__(base_url, api_version, timeout, session, auto_refresh_token)
|
|
59
|
-
self.client_id = client_id
|
|
60
|
-
self.client_secret = client_secret
|
|
61
|
-
self._access_token: Optional[str] = None
|
|
62
|
-
self.default_scope = default_scope
|
|
63
|
-
|
|
64
|
-
async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
|
|
65
|
-
"""
|
|
66
|
-
Получить access token используя Client Credentials Grant
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
scope: Запрашиваемые разрешения (разделенные пробелом)
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
TokenResponse с access_token
|
|
73
|
-
"""
|
|
74
|
-
form_data = {
|
|
75
|
-
"grant_type": "client_credentials",
|
|
76
|
-
"client_id": self.client_id,
|
|
77
|
-
"client_secret": self.client_secret,
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if scope:
|
|
81
|
-
form_data["scope"] = scope
|
|
82
|
-
|
|
83
|
-
data = await self.post("token", form_data=form_data)
|
|
84
|
-
token_response = TokenResponse(**data)
|
|
85
|
-
|
|
86
|
-
# Сохраняем токен
|
|
87
|
-
self._access_token = token_response.access_token
|
|
88
|
-
|
|
89
|
-
return token_response
|
|
90
|
-
|
|
91
|
-
async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
|
|
92
|
-
"""
|
|
93
|
-
Получить информацию о текущем пользователе/сервисе
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
access_token: Access token (если не указан, используется сохраненный)
|
|
97
|
-
|
|
98
|
-
Returns:
|
|
99
|
-
UserInfo с информацией (для микросервисов может быть ограниченная информация)
|
|
100
|
-
"""
|
|
101
|
-
access_token = access_token or self._access_token
|
|
102
|
-
|
|
103
|
-
if not access_token:
|
|
104
|
-
raise TokenError(
|
|
105
|
-
"Access token не найден. Вызовите get_access_token() сначала.")
|
|
106
|
-
|
|
107
|
-
data = await self.get("me", access_token=access_token)
|
|
108
|
-
return UserInfo(**data)
|
|
109
|
-
|
|
110
|
-
def set_access_token(self, access_token: str):
|
|
111
|
-
"""
|
|
112
|
-
Установить access token вручную
|
|
113
|
-
|
|
114
|
-
Args:
|
|
115
|
-
access_token: Access token
|
|
116
|
-
"""
|
|
117
|
-
self._access_token = access_token
|
|
118
|
-
|
|
119
|
-
def _get_access_token(self) -> Optional[str]:
|
|
120
|
-
"""Получить текущий access token (для BaseClient)"""
|
|
121
|
-
return self._access_token
|
|
122
|
-
|
|
123
|
-
async def _refresh_token(self) -> None:
|
|
124
|
-
"""Обновить access token (для авто-рефреша)"""
|
|
125
|
-
await self.get_access_token(self.default_scope)
|
|
126
|
-
|
|
127
|
-
def get_token(self) -> Optional[str]:
|
|
128
|
-
"""Получить текущий access token"""
|
|
129
|
-
return self._access_token
|
|
130
|
-
|
|
131
|
-
def get_token_payload(self, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
132
|
-
"""
|
|
133
|
-
Получить payload текущего JWT токена (без проверки подписи).
|
|
134
|
-
Удобно для чтения полей, вшитых SSO (например service_name).
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
access_token: Токен (если не указан, используется сохранённый)
|
|
138
|
-
|
|
139
|
-
Returns:
|
|
140
|
-
Словарь с полями payload или пустой словарь при ошибке
|
|
141
|
-
"""
|
|
142
|
-
token = access_token or self._access_token
|
|
143
|
-
if not token:
|
|
144
|
-
return {}
|
|
145
|
-
return _decode_jwt_payload(token)
|
|
146
|
-
|
|
147
|
-
def get_service_name(self, access_token: Optional[str] = None) -> Optional[str]:
|
|
148
|
-
"""
|
|
149
|
-
Получить название сервиса из JWT токена (поле service_name, вшитое SSO).
|
|
150
|
-
|
|
151
|
-
Args:
|
|
152
|
-
access_token: Токен (если не указан, используется сохранённый)
|
|
153
|
-
|
|
154
|
-
Returns:
|
|
155
|
-
Название сервиса или None, если токена нет или поле отсутствует
|
|
156
|
-
"""
|
|
157
|
-
payload = self.get_token_payload(access_token)
|
|
158
|
-
return payload.get("service_name")
|
|
159
|
-
|
|
160
|
-
async def request_with_auth(
|
|
161
|
-
self,
|
|
162
|
-
method: str,
|
|
163
|
-
endpoint: str,
|
|
164
|
-
json_data: Optional[dict] = None,
|
|
165
|
-
params: Optional[dict] = None,
|
|
166
|
-
auto_refresh: bool = True,
|
|
167
|
-
) -> dict:
|
|
168
|
-
"""
|
|
169
|
-
Выполнить запрос с автоматической авторизацией
|
|
170
|
-
|
|
171
|
-
Args:
|
|
172
|
-
method: HTTP метод (GET, POST, PUT, DELETE)
|
|
173
|
-
endpoint: API endpoint
|
|
174
|
-
json_data: JSON данные для тела запроса
|
|
175
|
-
params: Query параметры
|
|
176
|
-
auto_refresh: Автоматически получать токен, если его нет
|
|
177
|
-
|
|
178
|
-
Returns:
|
|
179
|
-
Распарсенный JSON ответ
|
|
180
|
-
"""
|
|
181
|
-
access_token = self._access_token
|
|
182
|
-
|
|
183
|
-
if not access_token and auto_refresh:
|
|
184
|
-
await self.get_access_token()
|
|
185
|
-
access_token = self._access_token
|
|
186
|
-
|
|
187
|
-
if not access_token:
|
|
188
|
-
raise TokenError(
|
|
189
|
-
"Access token не найден. Вызовите get_access_token() сначала.")
|
|
190
|
-
|
|
191
|
-
return await self._request(
|
|
192
|
-
method=method,
|
|
193
|
-
endpoint=endpoint,
|
|
194
|
-
access_token=access_token,
|
|
195
|
-
json_data=json_data,
|
|
196
|
-
params=params,
|
|
197
|
-
)
|
|
1
|
+
"""Клиент для микросервисного взаимодействия (Client Credentials)"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .base import BaseClient
|
|
8
|
+
from .models import TokenResponse, UserInfo
|
|
9
|
+
from .exceptions import TokenError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _decode_jwt_payload(token: str) -> Dict[str, Any]:
|
|
13
|
+
"""
|
|
14
|
+
Декодирует payload JWT без проверки подписи (только чтение полей).
|
|
15
|
+
Токен получен от SSO, используется для извлечения claim service_name.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
parts = token.split(".")
|
|
19
|
+
if len(parts) != 3:
|
|
20
|
+
return {}
|
|
21
|
+
payload_b64 = parts[1]
|
|
22
|
+
padding = 4 - len(payload_b64) % 4
|
|
23
|
+
if padding != 4:
|
|
24
|
+
payload_b64 += "=" * padding
|
|
25
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
26
|
+
return json.loads(payload_bytes.decode("utf-8"))
|
|
27
|
+
except Exception:
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ServiceClient(BaseClient):
|
|
32
|
+
"""Клиент для микросервисного взаимодействия с Client Credentials Grant"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
client_id: str,
|
|
38
|
+
client_secret: str,
|
|
39
|
+
api_version: str = "v1",
|
|
40
|
+
timeout: int = 30,
|
|
41
|
+
session=None,
|
|
42
|
+
auto_refresh_token: bool = True,
|
|
43
|
+
default_scope: Optional[str] = None,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Инициализация клиента для микросервисов
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
base_url: Базовый URL API
|
|
50
|
+
client_id: ID микросервиса
|
|
51
|
+
client_secret: Секрет микросервиса
|
|
52
|
+
api_version: Версия API
|
|
53
|
+
timeout: Таймаут запросов
|
|
54
|
+
session: Опциональная aiohttp сессия
|
|
55
|
+
auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
|
|
56
|
+
default_scope: Scope по умолчанию для автоматического получения токена
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(base_url, api_version, timeout, session, auto_refresh_token)
|
|
59
|
+
self.client_id = client_id
|
|
60
|
+
self.client_secret = client_secret
|
|
61
|
+
self._access_token: Optional[str] = None
|
|
62
|
+
self.default_scope = default_scope
|
|
63
|
+
|
|
64
|
+
async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
|
|
65
|
+
"""
|
|
66
|
+
Получить access token используя Client Credentials Grant
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
scope: Запрашиваемые разрешения (разделенные пробелом)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
TokenResponse с access_token
|
|
73
|
+
"""
|
|
74
|
+
form_data = {
|
|
75
|
+
"grant_type": "client_credentials",
|
|
76
|
+
"client_id": self.client_id,
|
|
77
|
+
"client_secret": self.client_secret,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if scope:
|
|
81
|
+
form_data["scope"] = scope
|
|
82
|
+
|
|
83
|
+
data = await self.post("token", form_data=form_data)
|
|
84
|
+
token_response = TokenResponse(**data)
|
|
85
|
+
|
|
86
|
+
# Сохраняем токен
|
|
87
|
+
self._access_token = token_response.access_token
|
|
88
|
+
|
|
89
|
+
return token_response
|
|
90
|
+
|
|
91
|
+
async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
|
|
92
|
+
"""
|
|
93
|
+
Получить информацию о текущем пользователе/сервисе
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
access_token: Access token (если не указан, используется сохраненный)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
UserInfo с информацией (для микросервисов может быть ограниченная информация)
|
|
100
|
+
"""
|
|
101
|
+
access_token = access_token or self._access_token
|
|
102
|
+
|
|
103
|
+
if not access_token:
|
|
104
|
+
raise TokenError(
|
|
105
|
+
"Access token не найден. Вызовите get_access_token() сначала.")
|
|
106
|
+
|
|
107
|
+
data = await self.get("me", access_token=access_token)
|
|
108
|
+
return UserInfo(**data)
|
|
109
|
+
|
|
110
|
+
def set_access_token(self, access_token: str):
|
|
111
|
+
"""
|
|
112
|
+
Установить access token вручную
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
access_token: Access token
|
|
116
|
+
"""
|
|
117
|
+
self._access_token = access_token
|
|
118
|
+
|
|
119
|
+
def _get_access_token(self) -> Optional[str]:
|
|
120
|
+
"""Получить текущий access token (для BaseClient)"""
|
|
121
|
+
return self._access_token
|
|
122
|
+
|
|
123
|
+
async def _refresh_token(self) -> None:
|
|
124
|
+
"""Обновить access token (для авто-рефреша)"""
|
|
125
|
+
await self.get_access_token(self.default_scope)
|
|
126
|
+
|
|
127
|
+
def get_token(self) -> Optional[str]:
|
|
128
|
+
"""Получить текущий access token"""
|
|
129
|
+
return self._access_token
|
|
130
|
+
|
|
131
|
+
def get_token_payload(self, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
132
|
+
"""
|
|
133
|
+
Получить payload текущего JWT токена (без проверки подписи).
|
|
134
|
+
Удобно для чтения полей, вшитых SSO (например service_name).
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
access_token: Токен (если не указан, используется сохранённый)
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Словарь с полями payload или пустой словарь при ошибке
|
|
141
|
+
"""
|
|
142
|
+
token = access_token or self._access_token
|
|
143
|
+
if not token:
|
|
144
|
+
return {}
|
|
145
|
+
return _decode_jwt_payload(token)
|
|
146
|
+
|
|
147
|
+
def get_service_name(self, access_token: Optional[str] = None) -> Optional[str]:
|
|
148
|
+
"""
|
|
149
|
+
Получить название сервиса из JWT токена (поле service_name, вшитое SSO).
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
access_token: Токен (если не указан, используется сохранённый)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Название сервиса или None, если токена нет или поле отсутствует
|
|
156
|
+
"""
|
|
157
|
+
payload = self.get_token_payload(access_token)
|
|
158
|
+
return payload.get("service_name")
|
|
159
|
+
|
|
160
|
+
async def request_with_auth(
|
|
161
|
+
self,
|
|
162
|
+
method: str,
|
|
163
|
+
endpoint: str,
|
|
164
|
+
json_data: Optional[dict] = None,
|
|
165
|
+
params: Optional[dict] = None,
|
|
166
|
+
auto_refresh: bool = True,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""
|
|
169
|
+
Выполнить запрос с автоматической авторизацией
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
method: HTTP метод (GET, POST, PUT, DELETE)
|
|
173
|
+
endpoint: API endpoint
|
|
174
|
+
json_data: JSON данные для тела запроса
|
|
175
|
+
params: Query параметры
|
|
176
|
+
auto_refresh: Автоматически получать токен, если его нет
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Распарсенный JSON ответ
|
|
180
|
+
"""
|
|
181
|
+
access_token = self._access_token
|
|
182
|
+
|
|
183
|
+
if not access_token and auto_refresh:
|
|
184
|
+
await self.get_access_token()
|
|
185
|
+
access_token = self._access_token
|
|
186
|
+
|
|
187
|
+
if not access_token:
|
|
188
|
+
raise TokenError(
|
|
189
|
+
"Access token не найден. Вызовите get_access_token() сначала.")
|
|
190
|
+
|
|
191
|
+
return await self._request(
|
|
192
|
+
method=method,
|
|
193
|
+
endpoint=endpoint,
|
|
194
|
+
access_token=access_token,
|
|
195
|
+
json_data=json_data,
|
|
196
|
+
params=params,
|
|
197
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|