sso-nebus 0.1.0__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.0/LICENSE +11 -0
- sso_nebus-0.1.0/PKG-INFO +211 -0
- sso_nebus-0.1.0/README.md +192 -0
- sso_nebus-0.1.0/pyproject.toml +19 -0
- sso_nebus-0.1.0/sso_nebus/__init__.py +30 -0
- sso_nebus-0.1.0/sso_nebus/base.py +204 -0
- sso_nebus-0.1.0/sso_nebus/exceptions.py +38 -0
- sso_nebus-0.1.0/sso_nebus/exmples/example_service.py +50 -0
- sso_nebus-0.1.0/sso_nebus/exmples/example_user.py +54 -0
- sso_nebus-0.1.0/sso_nebus/models.py +67 -0
- sso_nebus-0.1.0/sso_nebus/service_client.py +134 -0
- sso_nebus-0.1.0/sso_nebus/user_client.py +341 -0
sso_nebus-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Артём
|
|
4
|
+
|
|
5
|
+
Данный код лицензируется на условиях MIT License. См. полный текст лицензии ниже.
|
|
6
|
+
|
|
7
|
+
Разрешается использование, копирование, изменение, слияние, публикация, распространение, сублицензирование и/или продажа копий данного программного обеспечения, а также разрешение лицам, которым предоставляется данный программный продукт, делать это при соблюдении следующих условий:
|
|
8
|
+
|
|
9
|
+
Уведомление об авторских правах и данном разрешении должны быть включены во все копии или значительные части данного программного обеспечения.
|
|
10
|
+
|
|
11
|
+
Данный программный продукт предоставляется "как есть", без каких-либо гарантий, явных или подразумеваемых, включая, помимо прочего, гарантии товарной пригодности и пригодности для определенной цели. В любом случае автор(ы) или держатель авторских прав не несут ответственности за любые претензии, убытки или другие обязательства, будь то в действии контракта, деликте или других обстоятельствах, возникающие из или в связи с использованием данного программного обеспечения.
|
sso_nebus-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sso-nebus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python клиент для взаимодействия с MS Auth Service API
|
|
5
|
+
License: LICENSE
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Артем Костюченко
|
|
8
|
+
Author-email: kostyuchenko.work@gmail.com
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: aiohttp (>=3.13.2,<4.0.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SSO Nebus Client
|
|
20
|
+
|
|
21
|
+
Python клиент для взаимодействия с MS Auth Service API. Предоставляет удобные классы для работы с OAuth 2.0 Authorization Code Flow с PKCE (для пользователей) и Client Credentials Grant (для микросервисов).
|
|
22
|
+
|
|
23
|
+
## Установка
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Или если пакет опубликован:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install sso-nebus-client
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Быстрый старт
|
|
36
|
+
|
|
37
|
+
### Для пользователей (UserClient)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio
|
|
41
|
+
from sso_client import UserClient
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
# Создаем клиент
|
|
45
|
+
client = UserClient(
|
|
46
|
+
base_url="http://localhost:8000",
|
|
47
|
+
client_id="your_client_id",
|
|
48
|
+
redirect_uri="http://localhost:3000/callback"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Полный цикл авторизации
|
|
52
|
+
token_response = await client.full_auth_flow(
|
|
53
|
+
login="user@example.com",
|
|
54
|
+
password="password123",
|
|
55
|
+
scope="sso.admin.read sso.admin.create"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
print(f"Access token: {token_response.access_token}")
|
|
59
|
+
print(f"Refresh token: {token_response.refresh_token}")
|
|
60
|
+
|
|
61
|
+
# Получаем информацию о пользователе
|
|
62
|
+
user_info = await client.get_current_user()
|
|
63
|
+
print(f"Пользователь: {user_info.name} {user_info.surname}")
|
|
64
|
+
|
|
65
|
+
# Обновляем токен
|
|
66
|
+
new_token = await client.refresh_access_token()
|
|
67
|
+
print(f"Новый access token: {new_token.access_token}")
|
|
68
|
+
|
|
69
|
+
await client.close()
|
|
70
|
+
|
|
71
|
+
asyncio.run(main())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Пошаговая авторизация
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
from sso_client import UserClient
|
|
79
|
+
|
|
80
|
+
async def main():
|
|
81
|
+
client = UserClient(
|
|
82
|
+
base_url="http://localhost:8000",
|
|
83
|
+
client_id="your_client_id"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# 1. Получаем PKCE параметры
|
|
87
|
+
pkce_params = await client.get_pkce_params()
|
|
88
|
+
|
|
89
|
+
# 2. Инициируем авторизацию
|
|
90
|
+
auth_response = await client.authorize(
|
|
91
|
+
scope="sso.admin.read",
|
|
92
|
+
pkce_params=pkce_params
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 3. Выполняем логин
|
|
96
|
+
login_response = await client.login(
|
|
97
|
+
login="user@example.com",
|
|
98
|
+
password="password123",
|
|
99
|
+
session_id=auth_response.session_id
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 4. Обмениваем код на токены
|
|
103
|
+
token_response = await client.exchange_code_for_tokens(
|
|
104
|
+
authorization_code=login_response.authorization_code,
|
|
105
|
+
pkce_params=pkce_params
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
print(f"Токены получены: {token_response.access_token}")
|
|
109
|
+
|
|
110
|
+
await client.close()
|
|
111
|
+
|
|
112
|
+
asyncio.run(main())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Для микросервисов (ServiceClient)
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from sso_client import ServiceClient
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
# Создаем клиент для микросервиса
|
|
123
|
+
client = ServiceClient(
|
|
124
|
+
base_url="http://localhost:8000",
|
|
125
|
+
client_id="service_id",
|
|
126
|
+
client_secret="service_secret"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Получаем access token
|
|
130
|
+
token_response = await client.get_access_token(
|
|
131
|
+
scope="system.client.read system.client.edit"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
print(f"Access token: {token_response.access_token}")
|
|
135
|
+
|
|
136
|
+
# Выполняем запросы с авторизацией
|
|
137
|
+
user_info = await client.get_current_user()
|
|
138
|
+
|
|
139
|
+
# Или используем request_with_auth для любых endpoints
|
|
140
|
+
data = await client.request_with_auth(
|
|
141
|
+
method="GET",
|
|
142
|
+
endpoint="admin/users",
|
|
143
|
+
params={"skip": 0, "limit": 10}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
await client.close()
|
|
147
|
+
|
|
148
|
+
asyncio.run(main())
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Использование с async context manager
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
import asyncio
|
|
155
|
+
from sso_client import UserClient
|
|
156
|
+
|
|
157
|
+
async def main():
|
|
158
|
+
async with UserClient(
|
|
159
|
+
base_url="http://localhost:8000",
|
|
160
|
+
client_id="your_client_id"
|
|
161
|
+
) as client:
|
|
162
|
+
token_response = await client.full_auth_flow(
|
|
163
|
+
login="user@example.com",
|
|
164
|
+
password="password123"
|
|
165
|
+
)
|
|
166
|
+
# Сессия автоматически закроется при выходе
|
|
167
|
+
|
|
168
|
+
asyncio.run(main())
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## API Reference
|
|
172
|
+
|
|
173
|
+
### UserClient
|
|
174
|
+
|
|
175
|
+
Класс для пользовательского взаимодействия с OAuth 2.0 Authorization Code Flow с PKCE.
|
|
176
|
+
|
|
177
|
+
#### Методы
|
|
178
|
+
|
|
179
|
+
- `get_pkce_params()` - Получить PKCE параметры от сервера
|
|
180
|
+
- `authorize(scope, redirect_uri, pkce_params)` - Инициировать OAuth 2.0 flow
|
|
181
|
+
- `login(login, password, session_id)` - Выполнить аутентификацию
|
|
182
|
+
- `exchange_code_for_tokens(authorization_code, redirect_uri, pkce_params)` - Обменять код на токены
|
|
183
|
+
- `refresh_access_token(refresh_token)` - Обновить access token
|
|
184
|
+
- `get_current_user(access_token)` - Получить информацию о пользователе
|
|
185
|
+
- `logout(refresh_token)` - Выйти из системы
|
|
186
|
+
- `get_available_services()` - Получить список доступных микросервисов
|
|
187
|
+
- `full_auth_flow(login, password, scope, redirect_uri)` - Выполнить полный цикл авторизации
|
|
188
|
+
|
|
189
|
+
### ServiceClient
|
|
190
|
+
|
|
191
|
+
Класс для микросервисного взаимодействия с Client Credentials Grant.
|
|
192
|
+
|
|
193
|
+
#### Методы
|
|
194
|
+
|
|
195
|
+
- `get_access_token(scope)` - Получить access token
|
|
196
|
+
- `get_current_user(access_token)` - Получить информацию о текущем пользователе/сервисе
|
|
197
|
+
- `request_with_auth(method, endpoint, json_data, params, auto_refresh)` - Выполнить запрос с авторизацией
|
|
198
|
+
|
|
199
|
+
## Исключения
|
|
200
|
+
|
|
201
|
+
- `SSOClientError` - Базовое исключение
|
|
202
|
+
- `AuthenticationError` - Ошибка аутентификации (401)
|
|
203
|
+
- `AuthorizationError` - Ошибка авторизации (403)
|
|
204
|
+
- `APIError` - Ошибка API (4xx, 5xx)
|
|
205
|
+
- `TokenError` - Ошибка работы с токенами
|
|
206
|
+
|
|
207
|
+
## Лицензия
|
|
208
|
+
|
|
209
|
+
MIT
|
|
210
|
+
|
|
211
|
+
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# SSO Nebus Client
|
|
2
|
+
|
|
3
|
+
Python клиент для взаимодействия с MS Auth Service API. Предоставляет удобные классы для работы с OAuth 2.0 Authorization Code Flow с PKCE (для пользователей) и Client Credentials Grant (для микросервисов).
|
|
4
|
+
|
|
5
|
+
## Установка
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Или если пакет опубликован:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install sso-nebus-client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Быстрый старт
|
|
18
|
+
|
|
19
|
+
### Для пользователей (UserClient)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
from sso_client import UserClient
|
|
24
|
+
|
|
25
|
+
async def main():
|
|
26
|
+
# Создаем клиент
|
|
27
|
+
client = UserClient(
|
|
28
|
+
base_url="http://localhost:8000",
|
|
29
|
+
client_id="your_client_id",
|
|
30
|
+
redirect_uri="http://localhost:3000/callback"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Полный цикл авторизации
|
|
34
|
+
token_response = await client.full_auth_flow(
|
|
35
|
+
login="user@example.com",
|
|
36
|
+
password="password123",
|
|
37
|
+
scope="sso.admin.read sso.admin.create"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
print(f"Access token: {token_response.access_token}")
|
|
41
|
+
print(f"Refresh token: {token_response.refresh_token}")
|
|
42
|
+
|
|
43
|
+
# Получаем информацию о пользователе
|
|
44
|
+
user_info = await client.get_current_user()
|
|
45
|
+
print(f"Пользователь: {user_info.name} {user_info.surname}")
|
|
46
|
+
|
|
47
|
+
# Обновляем токен
|
|
48
|
+
new_token = await client.refresh_access_token()
|
|
49
|
+
print(f"Новый access token: {new_token.access_token}")
|
|
50
|
+
|
|
51
|
+
await client.close()
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Пошаговая авторизация
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
from sso_client import UserClient
|
|
61
|
+
|
|
62
|
+
async def main():
|
|
63
|
+
client = UserClient(
|
|
64
|
+
base_url="http://localhost:8000",
|
|
65
|
+
client_id="your_client_id"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 1. Получаем PKCE параметры
|
|
69
|
+
pkce_params = await client.get_pkce_params()
|
|
70
|
+
|
|
71
|
+
# 2. Инициируем авторизацию
|
|
72
|
+
auth_response = await client.authorize(
|
|
73
|
+
scope="sso.admin.read",
|
|
74
|
+
pkce_params=pkce_params
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# 3. Выполняем логин
|
|
78
|
+
login_response = await client.login(
|
|
79
|
+
login="user@example.com",
|
|
80
|
+
password="password123",
|
|
81
|
+
session_id=auth_response.session_id
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# 4. Обмениваем код на токены
|
|
85
|
+
token_response = await client.exchange_code_for_tokens(
|
|
86
|
+
authorization_code=login_response.authorization_code,
|
|
87
|
+
pkce_params=pkce_params
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
print(f"Токены получены: {token_response.access_token}")
|
|
91
|
+
|
|
92
|
+
await client.close()
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Для микросервисов (ServiceClient)
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import asyncio
|
|
101
|
+
from sso_client import ServiceClient
|
|
102
|
+
|
|
103
|
+
async def main():
|
|
104
|
+
# Создаем клиент для микросервиса
|
|
105
|
+
client = ServiceClient(
|
|
106
|
+
base_url="http://localhost:8000",
|
|
107
|
+
client_id="service_id",
|
|
108
|
+
client_secret="service_secret"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Получаем access token
|
|
112
|
+
token_response = await client.get_access_token(
|
|
113
|
+
scope="system.client.read system.client.edit"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
print(f"Access token: {token_response.access_token}")
|
|
117
|
+
|
|
118
|
+
# Выполняем запросы с авторизацией
|
|
119
|
+
user_info = await client.get_current_user()
|
|
120
|
+
|
|
121
|
+
# Или используем request_with_auth для любых endpoints
|
|
122
|
+
data = await client.request_with_auth(
|
|
123
|
+
method="GET",
|
|
124
|
+
endpoint="admin/users",
|
|
125
|
+
params={"skip": 0, "limit": 10}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
await client.close()
|
|
129
|
+
|
|
130
|
+
asyncio.run(main())
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Использование с async context manager
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import asyncio
|
|
137
|
+
from sso_client import UserClient
|
|
138
|
+
|
|
139
|
+
async def main():
|
|
140
|
+
async with UserClient(
|
|
141
|
+
base_url="http://localhost:8000",
|
|
142
|
+
client_id="your_client_id"
|
|
143
|
+
) as client:
|
|
144
|
+
token_response = await client.full_auth_flow(
|
|
145
|
+
login="user@example.com",
|
|
146
|
+
password="password123"
|
|
147
|
+
)
|
|
148
|
+
# Сессия автоматически закроется при выходе
|
|
149
|
+
|
|
150
|
+
asyncio.run(main())
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## API Reference
|
|
154
|
+
|
|
155
|
+
### UserClient
|
|
156
|
+
|
|
157
|
+
Класс для пользовательского взаимодействия с OAuth 2.0 Authorization Code Flow с PKCE.
|
|
158
|
+
|
|
159
|
+
#### Методы
|
|
160
|
+
|
|
161
|
+
- `get_pkce_params()` - Получить PKCE параметры от сервера
|
|
162
|
+
- `authorize(scope, redirect_uri, pkce_params)` - Инициировать OAuth 2.0 flow
|
|
163
|
+
- `login(login, password, session_id)` - Выполнить аутентификацию
|
|
164
|
+
- `exchange_code_for_tokens(authorization_code, redirect_uri, pkce_params)` - Обменять код на токены
|
|
165
|
+
- `refresh_access_token(refresh_token)` - Обновить access token
|
|
166
|
+
- `get_current_user(access_token)` - Получить информацию о пользователе
|
|
167
|
+
- `logout(refresh_token)` - Выйти из системы
|
|
168
|
+
- `get_available_services()` - Получить список доступных микросервисов
|
|
169
|
+
- `full_auth_flow(login, password, scope, redirect_uri)` - Выполнить полный цикл авторизации
|
|
170
|
+
|
|
171
|
+
### ServiceClient
|
|
172
|
+
|
|
173
|
+
Класс для микросервисного взаимодействия с Client Credentials Grant.
|
|
174
|
+
|
|
175
|
+
#### Методы
|
|
176
|
+
|
|
177
|
+
- `get_access_token(scope)` - Получить access token
|
|
178
|
+
- `get_current_user(access_token)` - Получить информацию о текущем пользователе/сервисе
|
|
179
|
+
- `request_with_auth(method, endpoint, json_data, params, auto_refresh)` - Выполнить запрос с авторизацией
|
|
180
|
+
|
|
181
|
+
## Исключения
|
|
182
|
+
|
|
183
|
+
- `SSOClientError` - Базовое исключение
|
|
184
|
+
- `AuthenticationError` - Ошибка аутентификации (401)
|
|
185
|
+
- `AuthorizationError` - Ошибка авторизации (403)
|
|
186
|
+
- `APIError` - Ошибка API (4xx, 5xx)
|
|
187
|
+
- `TokenError` - Ошибка работы с токенами
|
|
188
|
+
|
|
189
|
+
## Лицензия
|
|
190
|
+
|
|
191
|
+
MIT
|
|
192
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sso-nebus"
|
|
3
|
+
version = "0.1.0"
|
|
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
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSO Nebus Client - Python клиент для взаимодействия с MS Auth Service API
|
|
3
|
+
|
|
4
|
+
Этот пакет предоставляет удобные классы для работы с API аутентификации:
|
|
5
|
+
- UserClient: для пользовательского взаимодействия (OAuth 2.0 с PKCE)
|
|
6
|
+
- ServiceClient: для микросервисного взаимодействия (Client Credentials)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from user_client import UserClient
|
|
10
|
+
from service_client import ServiceClient
|
|
11
|
+
from exceptions import (
|
|
12
|
+
SSOClientError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
AuthorizationError,
|
|
15
|
+
APIError,
|
|
16
|
+
TokenError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"UserClient",
|
|
21
|
+
"ServiceClient",
|
|
22
|
+
"SSOClientError",
|
|
23
|
+
"AuthenticationError",
|
|
24
|
+
"AuthorizationError",
|
|
25
|
+
"APIError",
|
|
26
|
+
"TokenError",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Базовый класс для SSO клиентов"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from aiohttp import ClientSession, ClientResponse
|
|
8
|
+
|
|
9
|
+
from exceptions import (
|
|
10
|
+
APIError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
AuthorizationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseClient:
|
|
17
|
+
"""Базовый класс для всех SSO клиентов"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
api_version: str = "v1",
|
|
23
|
+
timeout: int = 30,
|
|
24
|
+
session: Optional[ClientSession] = None,
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Инициализация базового клиента
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
base_url: Базовый URL API (например, "http://localhost:8000")
|
|
31
|
+
api_version: Версия API (по умолчанию "v1")
|
|
32
|
+
timeout: Таймаут запросов в секундах
|
|
33
|
+
session: Опциональная aiohttp сессия (если не указана, создается новая)
|
|
34
|
+
"""
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self.api_version = api_version
|
|
37
|
+
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
|
38
|
+
self._session = session
|
|
39
|
+
self._own_session = session is None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def api_base_url(self) -> str:
|
|
43
|
+
"""Базовый URL для API endpoints"""
|
|
44
|
+
return urljoin(self.base_url, f"/api/{self.api_version}/")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def session(self) -> ClientSession:
|
|
48
|
+
"""Получить или создать aiohttp сессию"""
|
|
49
|
+
if self._session is None:
|
|
50
|
+
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
|
51
|
+
self._own_session = True
|
|
52
|
+
elif self._session.closed:
|
|
53
|
+
# Если сессия закрыта, создаем новую
|
|
54
|
+
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
|
55
|
+
self._own_session = True
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
async def close(self):
|
|
59
|
+
"""Закрыть сессию (если она была создана клиентом)"""
|
|
60
|
+
if self._own_session and self._session and not self._session.closed:
|
|
61
|
+
await self._session.close()
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self):
|
|
64
|
+
"""Поддержка async context manager"""
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
68
|
+
"""Закрытие сессии при выходе из context manager"""
|
|
69
|
+
await self.close()
|
|
70
|
+
|
|
71
|
+
def _build_url(self, endpoint: str) -> str:
|
|
72
|
+
"""Построить полный URL для endpoint"""
|
|
73
|
+
endpoint = endpoint.lstrip("/")
|
|
74
|
+
return urljoin(self.api_base_url, endpoint)
|
|
75
|
+
|
|
76
|
+
def _get_headers(self, access_token: Optional[str] = None) -> Dict[str, str]:
|
|
77
|
+
"""Получить заголовки для запроса"""
|
|
78
|
+
headers = {"Content-Type": "application/json"}
|
|
79
|
+
if access_token:
|
|
80
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
81
|
+
return headers
|
|
82
|
+
|
|
83
|
+
async def _handle_response(self, response: ClientResponse) -> Dict[str, Any]:
|
|
84
|
+
"""
|
|
85
|
+
Обработать HTTP ответ
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
response: aiohttp ClientResponse
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Распарсенный JSON ответ
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
APIError: При ошибках API
|
|
95
|
+
AuthenticationError: При ошибках аутентификации (401)
|
|
96
|
+
AuthorizationError: При ошибках авторизации (403)
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
data = await response.json()
|
|
100
|
+
except aiohttp.ContentTypeError:
|
|
101
|
+
# Если ответ не JSON, пытаемся получить текст
|
|
102
|
+
text = await response.text()
|
|
103
|
+
data = {"detail": text} if text else {
|
|
104
|
+
"detail": "Неизвестная ошибка"}
|
|
105
|
+
|
|
106
|
+
if response.status == 401:
|
|
107
|
+
detail = data.get("detail", "Ошибка аутентификации")
|
|
108
|
+
raise AuthenticationError(detail)
|
|
109
|
+
|
|
110
|
+
if response.status == 403:
|
|
111
|
+
detail = data.get("detail", "Ошибка авторизации")
|
|
112
|
+
raise AuthorizationError(detail)
|
|
113
|
+
|
|
114
|
+
if not response.ok:
|
|
115
|
+
detail = data.get("detail", f"Ошибка API: {response.status}")
|
|
116
|
+
raise APIError(detail, status_code=response.status)
|
|
117
|
+
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
async def _request(
|
|
121
|
+
self,
|
|
122
|
+
method: str,
|
|
123
|
+
endpoint: str,
|
|
124
|
+
access_token: Optional[str] = None,
|
|
125
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
126
|
+
form_data: Optional[Dict[str, Any]] = None,
|
|
127
|
+
params: Optional[Dict[str, Any]] = None,
|
|
128
|
+
) -> Dict[str, Any]:
|
|
129
|
+
"""
|
|
130
|
+
Выполнить HTTP запрос
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
method: HTTP метод (GET, POST, etc.)
|
|
134
|
+
endpoint: API endpoint (например, "auth/me")
|
|
135
|
+
access_token: Access token для авторизации (опционально)
|
|
136
|
+
json_data: JSON данные для тела запроса
|
|
137
|
+
form_data: Form data для тела запроса
|
|
138
|
+
params: Query параметры
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Распарсенный JSON ответ
|
|
142
|
+
"""
|
|
143
|
+
url = self._build_url(endpoint)
|
|
144
|
+
headers = self._get_headers(access_token)
|
|
145
|
+
|
|
146
|
+
# Если form_data, меняем Content-Type
|
|
147
|
+
if form_data:
|
|
148
|
+
headers.pop("Content-Type", None)
|
|
149
|
+
|
|
150
|
+
async with self.session.request(
|
|
151
|
+
method=method,
|
|
152
|
+
url=url,
|
|
153
|
+
headers=headers,
|
|
154
|
+
json=json_data,
|
|
155
|
+
data=form_data,
|
|
156
|
+
params=params,
|
|
157
|
+
) as response:
|
|
158
|
+
return await self._handle_response(response)
|
|
159
|
+
|
|
160
|
+
async def get(
|
|
161
|
+
self,
|
|
162
|
+
endpoint: str,
|
|
163
|
+
access_token: Optional[str] = None,
|
|
164
|
+
params: Optional[Dict[str, Any]] = None,
|
|
165
|
+
) -> Dict[str, Any]:
|
|
166
|
+
"""Выполнить GET запрос"""
|
|
167
|
+
return await self._request("GET", endpoint, access_token=access_token, params=params)
|
|
168
|
+
|
|
169
|
+
async def post(
|
|
170
|
+
self,
|
|
171
|
+
endpoint: str,
|
|
172
|
+
access_token: Optional[str] = None,
|
|
173
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
174
|
+
form_data: Optional[Dict[str, Any]] = None,
|
|
175
|
+
params: Optional[Dict[str, Any]] = None,
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""Выполнить POST запрос"""
|
|
178
|
+
return await self._request(
|
|
179
|
+
"POST",
|
|
180
|
+
endpoint,
|
|
181
|
+
access_token=access_token,
|
|
182
|
+
json_data=json_data,
|
|
183
|
+
form_data=form_data,
|
|
184
|
+
params=params,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def put(
|
|
188
|
+
self,
|
|
189
|
+
endpoint: str,
|
|
190
|
+
access_token: Optional[str] = None,
|
|
191
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
192
|
+
params: Optional[Dict[str, Any]] = None,
|
|
193
|
+
) -> Dict[str, Any]:
|
|
194
|
+
"""Выполнить PUT запрос"""
|
|
195
|
+
return await self._request("PUT", endpoint, access_token=access_token, json_data=json_data, params=params)
|
|
196
|
+
|
|
197
|
+
async def delete(
|
|
198
|
+
self,
|
|
199
|
+
endpoint: str,
|
|
200
|
+
access_token: Optional[str] = None,
|
|
201
|
+
params: Optional[Dict[str, Any]] = None,
|
|
202
|
+
) -> Dict[str, Any]:
|
|
203
|
+
"""Выполнить DELETE запрос"""
|
|
204
|
+
return await self._request("DELETE", endpoint, access_token=access_token, params=params)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Исключения для SSO клиента"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SSOClientError(Exception):
|
|
5
|
+
"""Базовое исключение для всех ошибок клиента"""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
super().__init__(self.message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationError(SSOClientError):
|
|
14
|
+
"""Ошибка аутентификации (401)"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Ошибка аутентификации"):
|
|
17
|
+
super().__init__(message, status_code=401)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthorizationError(SSOClientError):
|
|
21
|
+
"""Ошибка авторизации (403)"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str = "Ошибка авторизации"):
|
|
24
|
+
super().__init__(message, status_code=403)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIError(SSOClientError):
|
|
28
|
+
"""Ошибка API (4xx, 5xx)"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, status_code: int):
|
|
31
|
+
super().__init__(message, status_code=status_code)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TokenError(SSOClientError):
|
|
35
|
+
"""Ошибка работы с токенами"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str = "Ошибка работы с токеном"):
|
|
38
|
+
super().__init__(message)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Пример использования ServiceClient"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from service_client import ServiceClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def main():
|
|
8
|
+
# Создаем клиент для микросервиса
|
|
9
|
+
client = ServiceClient(
|
|
10
|
+
base_url="http://localhost:8000",
|
|
11
|
+
client_id="service_id",
|
|
12
|
+
client_secret="service_secret",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# Получаем access token
|
|
17
|
+
token_response = await client.get_access_token(
|
|
18
|
+
scope="system.client.read system.client.edit",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
print(f"Access token: {token_response.access_token[:50]}...")
|
|
22
|
+
print(f"Expires in: {token_response.expires_in} seconds")
|
|
23
|
+
print(f"Scope: {token_response.scope}")
|
|
24
|
+
|
|
25
|
+
# Получаем информацию о текущем пользователе/сервисе
|
|
26
|
+
user_info = await client.get_current_user()
|
|
27
|
+
print(f"\nClient ID: {user_info.id if hasattr(user_info, 'id') else 'N/A'}")
|
|
28
|
+
|
|
29
|
+
# Выполняем запросы с авторизацией
|
|
30
|
+
# Пример: получение списка пользователей (требует админских прав)
|
|
31
|
+
try:
|
|
32
|
+
data = await client.request_with_auth(
|
|
33
|
+
method="GET",
|
|
34
|
+
endpoint="admin/users",
|
|
35
|
+
params={"skip": 0, "limit": 10},
|
|
36
|
+
)
|
|
37
|
+
print(f"\nПолучено пользователей: {len(data) if isinstance(data, list) else 'N/A'}")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"\nОшибка при запросе: {e}")
|
|
40
|
+
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print(f"Ошибка: {e}")
|
|
43
|
+
|
|
44
|
+
finally:
|
|
45
|
+
await client.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Пример использования UserClient"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from user_client import UserClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
# Создаем клиент
|
|
10
|
+
client = UserClient(
|
|
11
|
+
base_url="http://localhost:8000",
|
|
12
|
+
client_id="your_client_id",
|
|
13
|
+
redirect_uri="http://localhost:3000/callback",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
# Полный цикл авторизации
|
|
18
|
+
token_response = await client.full_auth_flow(
|
|
19
|
+
login="user@example.com",
|
|
20
|
+
password="password123",
|
|
21
|
+
scope="sso.admin.read sso.admin.create",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
print(f"Access token: {token_response.access_token[:50]}...")
|
|
25
|
+
print(
|
|
26
|
+
f"Refresh token: {token_response.refresh_token[:50] if token_response.refresh_token else None}...")
|
|
27
|
+
print(f"Expires in: {token_response.expires_in} seconds")
|
|
28
|
+
|
|
29
|
+
# Получаем информацию о пользователе
|
|
30
|
+
user_info = await client.get_current_user()
|
|
31
|
+
print(f"\nПользователь: {user_info.name} {user_info.surname}")
|
|
32
|
+
print(f"Email: {user_info.email}")
|
|
33
|
+
print(f"Scopes: {user_info.scopes}")
|
|
34
|
+
|
|
35
|
+
# Обновляем токен
|
|
36
|
+
new_token = await client.refresh_access_token()
|
|
37
|
+
print(
|
|
38
|
+
f"\nНовый access token получен: {new_token.access_token[:50]}...")
|
|
39
|
+
|
|
40
|
+
# Получаем список доступных сервисов
|
|
41
|
+
services = await client.get_available_services()
|
|
42
|
+
print(f"\nДоступно сервисов: {len(services.services)}")
|
|
43
|
+
for service in services.services:
|
|
44
|
+
print(f" - {service.name} ({service.client_id})")
|
|
45
|
+
|
|
46
|
+
except Exception as e:
|
|
47
|
+
print(f"Ошибка: {e}")
|
|
48
|
+
|
|
49
|
+
finally:
|
|
50
|
+
await client.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pydantic модели для SSO клиента"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PKCEParams(BaseModel):
|
|
8
|
+
"""PKCE параметры для OAuth 2.0"""
|
|
9
|
+
|
|
10
|
+
code_verifier: str = Field(..., description="Code verifier для PKCE")
|
|
11
|
+
code_challenge: str = Field(..., description="Code challenge для PKCE")
|
|
12
|
+
state: str = Field(..., description="State parameter для CSRF защиты")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenResponse(BaseModel):
|
|
16
|
+
"""Ответ с токенами"""
|
|
17
|
+
|
|
18
|
+
access_token: str = Field(..., description="Access token")
|
|
19
|
+
token_type: str = Field(default="Bearer", description="Тип токена")
|
|
20
|
+
expires_in: int = Field(..., description="Время жизни токена в секундах")
|
|
21
|
+
refresh_token: Optional[str] = Field(
|
|
22
|
+
None, description="Refresh token (только для пользователей)")
|
|
23
|
+
scope: Optional[str] = Field(None, description="Разрешения (scopes)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserInfo(BaseModel):
|
|
27
|
+
"""Информация о пользователе"""
|
|
28
|
+
|
|
29
|
+
id: int = Field(..., description="ID пользователя")
|
|
30
|
+
email: str = Field(..., description="Email пользователя")
|
|
31
|
+
name: str = Field(..., description="Имя пользователя")
|
|
32
|
+
surname: str = Field(..., description="Фамилия пользователя")
|
|
33
|
+
lastname: Optional[str] = Field(None, description="Отчество пользователя")
|
|
34
|
+
scopes: list[str] = Field(default_factory=list,
|
|
35
|
+
description="Список разрешений пользователя")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ServiceInfo(BaseModel):
|
|
39
|
+
"""Информация о сервисе"""
|
|
40
|
+
|
|
41
|
+
client_id: str = Field(..., description="Уникальный идентификатор клиента")
|
|
42
|
+
name: Optional[str] = Field(None, description="Название сервиса")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ServicesList(BaseModel):
|
|
46
|
+
"""Список доступных сервисов"""
|
|
47
|
+
|
|
48
|
+
services: list[ServiceInfo] = Field(...,
|
|
49
|
+
description="Список всех активных микросервисов")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthorizeResponse(BaseModel):
|
|
53
|
+
"""Ответ на запрос авторизации"""
|
|
54
|
+
|
|
55
|
+
session_id: str = Field(...,
|
|
56
|
+
description="ID сессии для последующего логина")
|
|
57
|
+
message: str = Field(..., description="Сообщение для клиента")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LoginResponse(BaseModel):
|
|
61
|
+
"""Ответ на запрос логина"""
|
|
62
|
+
|
|
63
|
+
success: bool = Field(..., description="Успешность операции")
|
|
64
|
+
message: str = Field(..., description="Сообщение")
|
|
65
|
+
authorization_code: str = Field(...,
|
|
66
|
+
description="Authorization code для обмена на токены")
|
|
67
|
+
state: str = Field(..., description="State parameter для проверки")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Клиент для микросервисного взаимодействия (Client Credentials)"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from base import BaseClient
|
|
6
|
+
from models import TokenResponse, UserInfo
|
|
7
|
+
from exceptions import TokenError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServiceClient(BaseClient):
|
|
11
|
+
"""Клиент для микросервисного взаимодействия с Client Credentials Grant"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
base_url: str,
|
|
16
|
+
client_id: str,
|
|
17
|
+
client_secret: str,
|
|
18
|
+
api_version: str = "v1",
|
|
19
|
+
timeout: int = 30,
|
|
20
|
+
session=None,
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Инициализация клиента для микросервисов
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url: Базовый URL API
|
|
27
|
+
client_id: ID микросервиса
|
|
28
|
+
client_secret: Секрет микросервиса
|
|
29
|
+
api_version: Версия API
|
|
30
|
+
timeout: Таймаут запросов
|
|
31
|
+
session: Опциональная aiohttp сессия
|
|
32
|
+
"""
|
|
33
|
+
super().__init__(base_url, api_version, timeout, session)
|
|
34
|
+
self.client_id = client_id
|
|
35
|
+
self.client_secret = client_secret
|
|
36
|
+
self._access_token: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
|
|
39
|
+
"""
|
|
40
|
+
Получить access token используя Client Credentials Grant
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
scope: Запрашиваемые разрешения (разделенные пробелом)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
TokenResponse с access_token
|
|
47
|
+
"""
|
|
48
|
+
form_data = {
|
|
49
|
+
"grant_type": "client_credentials",
|
|
50
|
+
"client_id": self.client_id,
|
|
51
|
+
"client_secret": self.client_secret,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if scope:
|
|
55
|
+
form_data["scope"] = scope
|
|
56
|
+
|
|
57
|
+
data = await self.post("auth/token", form_data=form_data)
|
|
58
|
+
token_response = TokenResponse(**data)
|
|
59
|
+
|
|
60
|
+
# Сохраняем токен
|
|
61
|
+
self._access_token = token_response.access_token
|
|
62
|
+
|
|
63
|
+
return token_response
|
|
64
|
+
|
|
65
|
+
async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
|
|
66
|
+
"""
|
|
67
|
+
Получить информацию о текущем пользователе/сервисе
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
access_token: Access token (если не указан, используется сохраненный)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
UserInfo с информацией (для микросервисов может быть ограниченная информация)
|
|
74
|
+
"""
|
|
75
|
+
access_token = access_token or self._access_token
|
|
76
|
+
|
|
77
|
+
if not access_token:
|
|
78
|
+
raise TokenError(
|
|
79
|
+
"Access token не найден. Вызовите get_access_token() сначала.")
|
|
80
|
+
|
|
81
|
+
data = await self.get("auth/me", access_token=access_token)
|
|
82
|
+
return UserInfo(**data)
|
|
83
|
+
|
|
84
|
+
def set_access_token(self, access_token: str):
|
|
85
|
+
"""
|
|
86
|
+
Установить access token вручную
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
access_token: Access token
|
|
90
|
+
"""
|
|
91
|
+
self._access_token = access_token
|
|
92
|
+
|
|
93
|
+
def get_token(self) -> Optional[str]:
|
|
94
|
+
"""Получить текущий access token"""
|
|
95
|
+
return self._access_token
|
|
96
|
+
|
|
97
|
+
async def request_with_auth(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
endpoint: str,
|
|
101
|
+
json_data: Optional[dict] = None,
|
|
102
|
+
params: Optional[dict] = None,
|
|
103
|
+
auto_refresh: bool = True,
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""
|
|
106
|
+
Выполнить запрос с автоматической авторизацией
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
method: HTTP метод (GET, POST, PUT, DELETE)
|
|
110
|
+
endpoint: API endpoint
|
|
111
|
+
json_data: JSON данные для тела запроса
|
|
112
|
+
params: Query параметры
|
|
113
|
+
auto_refresh: Автоматически получать токен, если его нет
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Распарсенный JSON ответ
|
|
117
|
+
"""
|
|
118
|
+
access_token = self._access_token
|
|
119
|
+
|
|
120
|
+
if not access_token and auto_refresh:
|
|
121
|
+
await self.get_access_token()
|
|
122
|
+
access_token = self._access_token
|
|
123
|
+
|
|
124
|
+
if not access_token:
|
|
125
|
+
raise TokenError(
|
|
126
|
+
"Access token не найден. Вызовите get_access_token() сначала.")
|
|
127
|
+
|
|
128
|
+
return await self._request(
|
|
129
|
+
method=method,
|
|
130
|
+
endpoint=endpoint,
|
|
131
|
+
access_token=access_token,
|
|
132
|
+
json_data=json_data,
|
|
133
|
+
params=params,
|
|
134
|
+
)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Клиент для пользовательского взаимодействия (OAuth 2.0 с PKCE)"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import hashlib
|
|
5
|
+
import base64
|
|
6
|
+
import secrets
|
|
7
|
+
|
|
8
|
+
from base import BaseClient
|
|
9
|
+
from models import (
|
|
10
|
+
PKCEParams,
|
|
11
|
+
TokenResponse,
|
|
12
|
+
UserInfo,
|
|
13
|
+
AuthorizeResponse,
|
|
14
|
+
LoginResponse,
|
|
15
|
+
ServicesList,
|
|
16
|
+
)
|
|
17
|
+
from exceptions import TokenError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserClient(BaseClient):
|
|
21
|
+
"""Клиент для пользовательского взаимодействия с OAuth 2.0 Authorization Code Flow с PKCE"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str,
|
|
26
|
+
client_id: str,
|
|
27
|
+
redirect_uri: Optional[str] = None,
|
|
28
|
+
api_version: str = "v1",
|
|
29
|
+
timeout: int = 30,
|
|
30
|
+
session=None,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Инициализация клиента для пользователей
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
base_url: Базовый URL API
|
|
37
|
+
client_id: ID OAuth клиента
|
|
38
|
+
redirect_uri: URI для редиректа (опционально)
|
|
39
|
+
api_version: Версия API
|
|
40
|
+
timeout: Таймаут запросов
|
|
41
|
+
session: Опциональная aiohttp сессия
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(base_url, api_version, timeout, session)
|
|
44
|
+
self.client_id = client_id
|
|
45
|
+
self.redirect_uri = redirect_uri
|
|
46
|
+
self._pkce_params: Optional[PKCEParams] = None
|
|
47
|
+
self._access_token: Optional[str] = None
|
|
48
|
+
self._refresh_token: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _generate_code_verifier() -> str:
|
|
52
|
+
"""Генерация code_verifier для PKCE"""
|
|
53
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _generate_code_challenge(code_verifier: str) -> str:
|
|
57
|
+
"""Генерация code_challenge из code_verifier"""
|
|
58
|
+
sha256_hash = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
|
59
|
+
return base64.urlsafe_b64encode(sha256_hash).decode("utf-8").rstrip("=")
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _generate_state() -> str:
|
|
63
|
+
"""Генерация state для CSRF защиты"""
|
|
64
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
|
|
65
|
+
|
|
66
|
+
async def get_pkce_params(self) -> PKCEParams:
|
|
67
|
+
"""
|
|
68
|
+
Получить PKCE параметры от сервера
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
PKCEParams с code_verifier, code_challenge и state
|
|
72
|
+
"""
|
|
73
|
+
data = await self.get("auth/pkce-params")
|
|
74
|
+
self._pkce_params = PKCEParams(**data)
|
|
75
|
+
return self._pkce_params
|
|
76
|
+
|
|
77
|
+
async def authorize(
|
|
78
|
+
self,
|
|
79
|
+
scope: Optional[str] = None,
|
|
80
|
+
redirect_uri: Optional[str] = None,
|
|
81
|
+
pkce_params: Optional[PKCEParams] = None,
|
|
82
|
+
) -> AuthorizeResponse:
|
|
83
|
+
"""
|
|
84
|
+
Инициировать OAuth 2.0 Authorization Code Flow
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
scope: Запрашиваемые разрешения (разделенные пробелом)
|
|
88
|
+
redirect_uri: URI для редиректа (если не указан, используется из конструктора)
|
|
89
|
+
pkce_params: PKCE параметры (если не указаны, получаются автоматически)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
AuthorizeResponse с session_id для последующего логина
|
|
93
|
+
"""
|
|
94
|
+
# Получаем PKCE параметры, если не переданы
|
|
95
|
+
if pkce_params is None:
|
|
96
|
+
if self._pkce_params is None:
|
|
97
|
+
pkce_params = await self.get_pkce_params()
|
|
98
|
+
else:
|
|
99
|
+
pkce_params = self._pkce_params
|
|
100
|
+
else:
|
|
101
|
+
self._pkce_params = pkce_params
|
|
102
|
+
|
|
103
|
+
redirect_uri = redirect_uri or self.redirect_uri
|
|
104
|
+
|
|
105
|
+
params = {
|
|
106
|
+
"response_type": "code",
|
|
107
|
+
"client_id": self.client_id,
|
|
108
|
+
"state": pkce_params.state,
|
|
109
|
+
"code_challenge": pkce_params.code_challenge,
|
|
110
|
+
"code_challenge_method": "S256",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if redirect_uri:
|
|
114
|
+
params["redirect_uri"] = redirect_uri
|
|
115
|
+
|
|
116
|
+
if scope:
|
|
117
|
+
params["scope"] = scope
|
|
118
|
+
|
|
119
|
+
data = await self.get("auth/authorize", params=params)
|
|
120
|
+
return AuthorizeResponse(**data)
|
|
121
|
+
|
|
122
|
+
async def login(
|
|
123
|
+
self,
|
|
124
|
+
login: str,
|
|
125
|
+
password: str,
|
|
126
|
+
session_id: str,
|
|
127
|
+
) -> LoginResponse:
|
|
128
|
+
"""
|
|
129
|
+
Выполнить аутентификацию пользователя
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
login: Логин пользователя
|
|
133
|
+
password: Пароль пользователя
|
|
134
|
+
session_id: ID сессии из метода authorize()
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
LoginResponse с authorization_code
|
|
138
|
+
"""
|
|
139
|
+
json_data = {
|
|
140
|
+
"session_id": session_id,
|
|
141
|
+
"login": login,
|
|
142
|
+
"password": password,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
data = await self.post("auth/login", json_data=json_data)
|
|
146
|
+
return LoginResponse(**data)
|
|
147
|
+
|
|
148
|
+
async def exchange_code_for_tokens(
|
|
149
|
+
self,
|
|
150
|
+
authorization_code: str,
|
|
151
|
+
redirect_uri: Optional[str] = None,
|
|
152
|
+
pkce_params: Optional[PKCEParams] = None,
|
|
153
|
+
) -> TokenResponse:
|
|
154
|
+
"""
|
|
155
|
+
Обменять authorization code на токены
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
authorization_code: Authorization code из метода login()
|
|
159
|
+
redirect_uri: Redirect URI (если не указан, используется из конструктора)
|
|
160
|
+
pkce_params: PKCE параметры (если не указаны, используются сохраненные)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
TokenResponse с access_token и refresh_token
|
|
164
|
+
"""
|
|
165
|
+
if pkce_params is None:
|
|
166
|
+
if self._pkce_params is None:
|
|
167
|
+
raise TokenError(
|
|
168
|
+
"PKCE параметры не найдены. Вызовите get_pkce_params() или authorize() сначала.")
|
|
169
|
+
pkce_params = self._pkce_params
|
|
170
|
+
|
|
171
|
+
redirect_uri = redirect_uri or self.redirect_uri
|
|
172
|
+
|
|
173
|
+
form_data = {
|
|
174
|
+
"grant_type": "authorization_code",
|
|
175
|
+
"code": authorization_code,
|
|
176
|
+
"client_id": self.client_id,
|
|
177
|
+
"code_verifier": pkce_params.code_verifier,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if redirect_uri:
|
|
181
|
+
form_data["redirect_uri"] = redirect_uri
|
|
182
|
+
|
|
183
|
+
data = await self.post("auth/token", form_data=form_data)
|
|
184
|
+
token_response = TokenResponse(**data)
|
|
185
|
+
|
|
186
|
+
# Сохраняем токены
|
|
187
|
+
self._access_token = token_response.access_token
|
|
188
|
+
self._refresh_token = token_response.refresh_token
|
|
189
|
+
|
|
190
|
+
return token_response
|
|
191
|
+
|
|
192
|
+
async def refresh_access_token(self, refresh_token: Optional[str] = None) -> TokenResponse:
|
|
193
|
+
"""
|
|
194
|
+
Обновить access token используя refresh token
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
refresh_token: Refresh token (если не указан, используется сохраненный)
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
TokenResponse с новым access_token и refresh_token
|
|
201
|
+
"""
|
|
202
|
+
refresh_token = refresh_token or self._refresh_token
|
|
203
|
+
|
|
204
|
+
if not refresh_token:
|
|
205
|
+
raise TokenError(
|
|
206
|
+
"Refresh token не найден. Выполните авторизацию сначала.")
|
|
207
|
+
|
|
208
|
+
form_data = {
|
|
209
|
+
"grant_type": "refresh_token",
|
|
210
|
+
"refresh_token": refresh_token,
|
|
211
|
+
"client_id": self.client_id,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
data = await self.post("auth/token", form_data=form_data)
|
|
215
|
+
token_response = TokenResponse(**data)
|
|
216
|
+
|
|
217
|
+
# Обновляем токены
|
|
218
|
+
self._access_token = token_response.access_token
|
|
219
|
+
self._refresh_token = token_response.refresh_token
|
|
220
|
+
|
|
221
|
+
return token_response
|
|
222
|
+
|
|
223
|
+
async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
|
|
224
|
+
"""
|
|
225
|
+
Получить информацию о текущем пользователе
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
access_token: Access token (если не указан, используется сохраненный)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
UserInfo с информацией о пользователе
|
|
232
|
+
"""
|
|
233
|
+
access_token = access_token or self._access_token
|
|
234
|
+
|
|
235
|
+
if not access_token:
|
|
236
|
+
raise TokenError(
|
|
237
|
+
"Access token не найден. Выполните авторизацию сначала.")
|
|
238
|
+
|
|
239
|
+
data = await self.get("auth/me", access_token=access_token)
|
|
240
|
+
return UserInfo(**data)
|
|
241
|
+
|
|
242
|
+
async def logout(self, refresh_token: Optional[str] = None) -> dict:
|
|
243
|
+
"""
|
|
244
|
+
Выйти из системы и отозвать refresh token
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
refresh_token: Refresh token для отзыва (если не указан, используется сохраненный)
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Словарь с сообщением об успешном выходе
|
|
251
|
+
"""
|
|
252
|
+
refresh_token = refresh_token or self._refresh_token
|
|
253
|
+
|
|
254
|
+
if not refresh_token:
|
|
255
|
+
raise TokenError("Refresh token не найден.")
|
|
256
|
+
|
|
257
|
+
form_data = {"refresh_token": refresh_token}
|
|
258
|
+
|
|
259
|
+
data = await self.post("auth/logout", form_data=form_data)
|
|
260
|
+
|
|
261
|
+
# Очищаем токены
|
|
262
|
+
self._access_token = None
|
|
263
|
+
self._refresh_token = None
|
|
264
|
+
self._pkce_params = None
|
|
265
|
+
|
|
266
|
+
return data
|
|
267
|
+
|
|
268
|
+
async def get_available_services(self) -> ServicesList:
|
|
269
|
+
"""
|
|
270
|
+
Получить список всех доступных микросервисов
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
ServicesList со списком активных микросервисов
|
|
274
|
+
"""
|
|
275
|
+
data = await self.get("auth/services")
|
|
276
|
+
return ServicesList(**data)
|
|
277
|
+
|
|
278
|
+
def get_access_token(self) -> Optional[str]:
|
|
279
|
+
"""Получить текущий access token"""
|
|
280
|
+
return self._access_token
|
|
281
|
+
|
|
282
|
+
def get_refresh_token(self) -> Optional[str]:
|
|
283
|
+
"""Получить текущий refresh token"""
|
|
284
|
+
return self._refresh_token
|
|
285
|
+
|
|
286
|
+
def set_tokens(self, access_token: str, refresh_token: Optional[str] = None):
|
|
287
|
+
"""
|
|
288
|
+
Установить токены вручную
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
access_token: Access token
|
|
292
|
+
refresh_token: Refresh token (опционально)
|
|
293
|
+
"""
|
|
294
|
+
self._access_token = access_token
|
|
295
|
+
if refresh_token:
|
|
296
|
+
self._refresh_token = refresh_token
|
|
297
|
+
|
|
298
|
+
async def full_auth_flow(
|
|
299
|
+
self,
|
|
300
|
+
login: str,
|
|
301
|
+
password: str,
|
|
302
|
+
scope: Optional[str] = None,
|
|
303
|
+
redirect_uri: Optional[str] = None,
|
|
304
|
+
) -> TokenResponse:
|
|
305
|
+
"""
|
|
306
|
+
Выполнить полный цикл авторизации (удобный метод)
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
login: Логин пользователя
|
|
310
|
+
password: Пароль пользователя
|
|
311
|
+
scope: Запрашиваемые разрешения
|
|
312
|
+
redirect_uri: URI для редиректа
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
TokenResponse с токенами
|
|
316
|
+
"""
|
|
317
|
+
# 1. Получаем PKCE параметры
|
|
318
|
+
pkce_params = await self.get_pkce_params()
|
|
319
|
+
|
|
320
|
+
# 2. Инициируем авторизацию
|
|
321
|
+
auth_response = await self.authorize(
|
|
322
|
+
scope=scope,
|
|
323
|
+
redirect_uri=redirect_uri,
|
|
324
|
+
pkce_params=pkce_params,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# 3. Выполняем логин
|
|
328
|
+
login_response = await self.login(
|
|
329
|
+
login=login,
|
|
330
|
+
password=password,
|
|
331
|
+
session_id=auth_response.session_id,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# 4. Обмениваем код на токены
|
|
335
|
+
token_response = await self.exchange_code_for_tokens(
|
|
336
|
+
authorization_code=login_response.authorization_code,
|
|
337
|
+
redirect_uri=redirect_uri,
|
|
338
|
+
pkce_params=pkce_params,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return token_response
|