27 03 25
Доделал API функции для авторификации и работы с сессиями сервер и документ для разработчиков по Авторификациии и серверам Всё работает
This commit is contained in:
parent
51de9779e3
commit
1aabcf4d80
@ -4,11 +4,17 @@
|
||||
|
||||
## Список документов
|
||||
|
||||
0. **API/01_Auth_and_Sessions_API.md**
|
||||
API-глава для разработчиков: транспортный JSON-конверт, форматы запросов/ответов, создание и вход в сессию, `session_key`, `storagePwd`, подписи и совместимость версий.
|
||||
0. **API/00_Common_API_Format.md**
|
||||
Общий формат JSON-запросов и JSON-ответов по всему API: `op`, `requestId`, `status`, `ok`, `payload`, единые правила успеха и ошибок.
|
||||
|
||||
0. **API/02_User_Registration_API.md**
|
||||
Временная глава API по регистрации пользователя: текущая заглушка `AddUser`, ограничения схемы и пометка о будущем переходе на полноценную регистрацию через Solana.
|
||||
0. **API/01_User_Registration_API.md**
|
||||
Временная глава API по регистрации пользователя: `AddUser` и временный `GetUser`, с пометкой о будущем переходе проверки identity напрямую через Solana.
|
||||
|
||||
0. **API/02_Authentication_API.md**
|
||||
Глава API по авторизации: `AuthChallenge`, `CreateAuthSession`, `SessionChallenge`, `SessionLogin`, подписи, `deviceKey`, `sessionKey`.
|
||||
|
||||
0. **API/03_Session_Management_API.md**
|
||||
Глава API по управлению сессиями: `ListSessions` и `CloseActiveSession`.
|
||||
|
||||
1. **01_Connection_and_Sessions.md**
|
||||
Процесс подключения к WebSocket, авторизация (двухшаговая), создание сессии, вход в существующую сессию, просмотр и закрытие сессий.
|
||||
|
||||
130
Dev_Docs/API/00_Common_API_Format.md
Normal file
130
Dev_Docs/API/00_Common_API_Format.md
Normal file
@ -0,0 +1,130 @@
|
||||
# API для разработчиков: Общий формат запросов и ответов
|
||||
|
||||
Этот файл описывает не конкретные операции, а общий wire-контракт всего API сервера.
|
||||
|
||||
Здесь зафиксировано:
|
||||
|
||||
- как выглядит любой запрос;
|
||||
- как выглядит любой успешный ответ;
|
||||
- как выглядит любой ответ с ошибкой;
|
||||
- какие поля являются обязательными для всех операций;
|
||||
- как клиент должен интерпретировать `status`, `ok` и `payload`.
|
||||
|
||||
Логика простая: сначала клиент и сервер договариваются о едином формате конверта, и только потом в остальных документах уже описываются конкретные методы и их поля.
|
||||
|
||||
## 1. Общий формат запроса
|
||||
|
||||
Все запросы по WebSocket используют один и тот же JSON-конверт:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "OperationName",
|
||||
"requestId": "req-001",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Поля
|
||||
|
||||
- `op` — имя операции.
|
||||
- `requestId` — клиентский идентификатор запроса.
|
||||
- `payload` — объект параметров операции.
|
||||
|
||||
---
|
||||
|
||||
## 2. Общий формат успешного ответа
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "OperationName",
|
||||
"requestId": "req-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Общий формат ответа с ошибкой
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "OperationName",
|
||||
"requestId": "req-001",
|
||||
"status": 400,
|
||||
"ok": false,
|
||||
"error": "BAD_REQUEST",
|
||||
"message": "Human readable description",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Обязательные правила
|
||||
|
||||
- Сервер возвращает `op` в каждом ответе.
|
||||
- Сервер возвращает `requestId` в каждом ответе без изменений.
|
||||
- Сервер возвращает `status` в каждом ответе.
|
||||
- Сервер возвращает `ok` в каждом ответе.
|
||||
- Сервер всегда возвращает `payload` как объект.
|
||||
- Даже при отсутствии данных сервер возвращает `payload: {}`.
|
||||
- `ok` находится на верхнем уровне ответа, а не внутри `payload`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Правило интерпретации
|
||||
|
||||
Источник истины — `status`.
|
||||
|
||||
- если `status` в диапазоне `200..299`, то ответ успешный и `ok` должен быть `true`;
|
||||
- если `status` вне диапазона `200..299`, то ответ ошибочный и `ok` должен быть `false`.
|
||||
|
||||
Запрещённые состояния:
|
||||
|
||||
- `status = 200` и `ok = false`;
|
||||
- `status = 400` и `ok = true`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Общие правила формата
|
||||
|
||||
- Все строки подписи и challenge собираются в UTF-8.
|
||||
- Временные метки передаются как Unix time в миллисекундах.
|
||||
- Бинарные поля передаются строками Base64.
|
||||
- При ошибке `error` — это машинный код причины.
|
||||
- При ошибке `message` — человекочитаемое описание причины.
|
||||
|
||||
---
|
||||
|
||||
## 7. Общие коды ошибок
|
||||
|
||||
Ниже перечислены коды ошибок, которые не привязаны к одной конкретной операции и могут встречаться в разных местах API.
|
||||
|
||||
- `400 / EMPTY_JSON` — клиент отправил пустое или полностью отсутствующее JSON-сообщение.
|
||||
- `400 / NO_OP` — в корневом объекте не передано поле `op`.
|
||||
- `400 / UNKNOWN_OP` — сервер не знает такую операцию.
|
||||
- `400 / NO_PAYLOAD` — в корневом объекте отсутствует `payload`.
|
||||
- `400 / BAD_PAYLOAD` — `payload` передан, но это не JSON-объект.
|
||||
- `400 / BAD_REQUEST_FORMAT` — JSON-конверт формально валиден, но поля операции не удалось распарсить в ожидаемый формат.
|
||||
- `500 / INTERNAL_HANDLER_ERROR` — в handler конкретной операции случилась непредвиденная серверная ошибка.
|
||||
- `500 / INTERNAL_ERROR` — произошла внутренняя ошибка на уровне общего JSON-процессора или другого серверного слоя.
|
||||
|
||||
Общее правило для dev/test этапа:
|
||||
|
||||
- `message` в таких ошибках должен быть коротким, но полезным;
|
||||
- по возможности сервер добавляет тип исключения и краткую деталь причины;
|
||||
- это сделано для упрощения интеграционных тестов и отладки;
|
||||
- позже для production этот уровень детализации может быть уменьшен.
|
||||
|
||||
---
|
||||
|
||||
## 8. Короткое резюме
|
||||
|
||||
- Запросы всегда идут как `op + requestId + payload`.
|
||||
- Ответы всегда идут как `op + requestId + status + ok + payload`.
|
||||
- Ошибки всегда возвращают `ok: false`, `error`, `message`, `payload: {}`.
|
||||
@ -1,507 +0,0 @@
|
||||
# API для разработчиков: Авторизация и сессии
|
||||
|
||||
## Статус документа
|
||||
|
||||
Это **первая глава API-спецификации для клиентов**.
|
||||
|
||||
Документ фиксирует:
|
||||
|
||||
- единый JSON-формат запросов и ответов по WebSocket;
|
||||
- роли `device key`, `session_key` и `storagePwd`;
|
||||
- целевой формат подписываемых строк для авторизации;
|
||||
- совместимость между текущей реализацией сервера и предлагаемым расширением.
|
||||
|
||||
---
|
||||
|
||||
## 1. Транспортный конверт
|
||||
|
||||
Все клиентские вызовы идут через WebSocket в общем JSON-конверте:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "OperationName",
|
||||
"requestId": "req-001",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Поля запроса
|
||||
|
||||
- `op` — имя операции.
|
||||
- `requestId` — уникальный идентификатор запроса на стороне клиента.
|
||||
- `payload` — объект с параметрами операции.
|
||||
|
||||
### Базовый формат ответа
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "req-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ с ошибкой:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "req-001",
|
||||
"status": 400,
|
||||
"error": "BAD_REQUEST",
|
||||
"message": "Human readable description"
|
||||
}
|
||||
```
|
||||
|
||||
### Общие правила
|
||||
|
||||
- Все строки подписи и challenge собираются в UTF-8.
|
||||
- Временные метки передаются в `timeMs` как Unix time в миллисекундах.
|
||||
- Бинарные поля передаются как Base64-строки.
|
||||
- `requestId` должен возвращаться сервером без изменений.
|
||||
|
||||
---
|
||||
|
||||
## 2. Роли ключей и секретов
|
||||
|
||||
### `device key`
|
||||
|
||||
Постоянный ключ устройства или аккаунта, которым клиент подтверждает право создать новую сессию.
|
||||
|
||||
Используется для:
|
||||
|
||||
- `CreateAuthSession`
|
||||
|
||||
### `session_key`
|
||||
|
||||
Клиент **сам создаёт** отдельный ключ сессии и передаёт на сервер только публичную часть.
|
||||
|
||||
Этот ключ используется для:
|
||||
|
||||
- `SessionLogin`
|
||||
- последующих перевходов в уже созданную сессию
|
||||
|
||||
В API клиент передаёт `sessionKey` целиком одной строкой, и сервер хранит `active_sessions.session_key` тоже целиком одной строкой.
|
||||
|
||||
### `storagePwd`
|
||||
|
||||
`storagePwd` тоже **генерируется и передаётся клиентом** при создании сессии.
|
||||
|
||||
Сервер:
|
||||
|
||||
- сохраняет это значение в составе активной сессии;
|
||||
- возвращает его клиенту после успешного `SessionLogin`.
|
||||
|
||||
Это нужно, чтобы клиент мог восстановить доступ к локально/серверно зашифрованному хранилищу сессии.
|
||||
|
||||
---
|
||||
|
||||
## 3. Формат `session_key` с префиксом алгоритма
|
||||
|
||||
Чтобы поддерживать разные аппаратные и программные типы ключей, `session_key` рекомендуется хранить и передавать не как "просто base64", а как строку с явным префиксом алгоритма:
|
||||
|
||||
```text
|
||||
<algorithm>/<public-key-data>
|
||||
```
|
||||
|
||||
Примеры:
|
||||
|
||||
```text
|
||||
ed25519/MCowBQYDK2VwAyEA2I7...
|
||||
secp256r1/BBD9LVa8gk9...
|
||||
rsa2048/MIIBIjANBgkqh...
|
||||
```
|
||||
|
||||
### Зачем это нужно
|
||||
|
||||
- у разных устройств разный набор аппаратно поддерживаемых ключей;
|
||||
- серверу проще понимать, какой верификатор использовать;
|
||||
- формат можно расширять без миграции всей таблицы сессий.
|
||||
|
||||
### Рекомендация по полю API
|
||||
|
||||
Во внешнем API лучше использовать поле:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
Если сервер внутри пока хранит старое поле `sessionPubKeyB64`, допускается переходный слой, который:
|
||||
|
||||
- принимает `sessionKey`;
|
||||
- разбирает префикс алгоритма;
|
||||
- сохраняет алгоритм и публичный ключ раздельно либо в одном поле.
|
||||
|
||||
---
|
||||
|
||||
## 4. Поток авторизации
|
||||
|
||||
Поддерживаются два базовых сценария:
|
||||
|
||||
1. Создание новой сессии.
|
||||
2. Вход в существующую сессию.
|
||||
|
||||
---
|
||||
|
||||
## 5. Создание новой сессии
|
||||
|
||||
### Шаг 1. `AuthChallenge`
|
||||
|
||||
Клиент запрашивает nonce для логина.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AuthChallenge",
|
||||
"requestId": "auth-001",
|
||||
"payload": {
|
||||
"login": "alice"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "auth-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11",
|
||||
"expiresInMs": 30000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Назначение:
|
||||
|
||||
- сервер убеждается, что пользователь существует;
|
||||
- сервер связывает `authNonce` с текущим WebSocket-соединением;
|
||||
- nonce одноразовый и живёт ограниченное время.
|
||||
|
||||
### Шаг 2. `CreateAuthSession`
|
||||
|
||||
Клиент:
|
||||
|
||||
- генерирует новый `session_key`;
|
||||
- генерирует или выбирает `storagePwd`;
|
||||
- подписывает строку создания сессии своим `device key`.
|
||||
|
||||
#### Целевой формат запроса
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CreateAuthSession",
|
||||
"requestId": "create-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
|
||||
"timeMs": 1774600000123,
|
||||
"authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11",
|
||||
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
|
||||
"signatureB64": "BASE64_SIGNATURE_BY_DEVICE_KEY",
|
||||
"clientInfo": "Android 15; Pixel 9"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Целевая строка для подписи
|
||||
|
||||
Рекомендуемый формат:
|
||||
|
||||
```text
|
||||
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
AUTH_CREATE_SESSION:alice:ed25519/BASE64_PUBLIC_KEY:BASE64_OR_APP_SPECIFIC_SECRET:1774600000123:8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11
|
||||
```
|
||||
|
||||
### Почему `sessionKey` и `storagePwd` нужно включить в подпись
|
||||
|
||||
- сервер получает криптографическое подтверждение того, какие именно значения утвердил клиент;
|
||||
- снижается риск подмены `session_key` между клиентом и сервером;
|
||||
- `storagePwd` становится частью подтверждённого набора параметров создания сессии.
|
||||
|
||||
### Дополнительная проверка `deviceKey`
|
||||
|
||||
Перед проверкой подписи сервер должен:
|
||||
|
||||
1. загрузить актуальный `device_key` пользователя;
|
||||
2. сравнить его со значением `payload.deviceKey`;
|
||||
3. только после совпадения ключей проверять подпись.
|
||||
|
||||
Если ключ не совпадает, сервер должен возвращать ошибку о том, что ключ не соответствует актуальной версии.
|
||||
|
||||
На будущее:
|
||||
|
||||
- для сценария обновления `device_key` желательно добавить дополнительную проверку актуального ключа через Solana;
|
||||
- если и после этого ключ не подтверждается, сервер всё равно должен возвращать ошибку о несовпадении актуального ключа.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "create-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"createdAtMs": 1774600000201
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Вход в существующую сессию
|
||||
|
||||
### Шаг 1. `SessionChallenge`
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionChallenge",
|
||||
"requestId": "sch-001",
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "sch-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"nonce": "0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66",
|
||||
"expiresInMs": 30000,
|
||||
"sessionKeyAlgorithm": "ed25519"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Шаг 2. `SessionLogin`
|
||||
|
||||
Клиент подписывает challenge приватной частью соответствующего `session_key`.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionLogin",
|
||||
"requestId": "slogin-001",
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"timeMs": 1774600010456,
|
||||
"signatureB64": "BASE64_SIGNATURE_BY_SESSION_KEY",
|
||||
"clientInfo": "Android 15; Pixel 9"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Строка для подписи:
|
||||
|
||||
```text
|
||||
SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66
|
||||
```
|
||||
|
||||
### Дополнительная проверка `sessionKey`
|
||||
|
||||
Перед проверкой подписи сервер должен:
|
||||
|
||||
1. загрузить `active_sessions.session_key` по `sessionId`;
|
||||
2. сравнить его со значением `payload.sessionKey`;
|
||||
3. только после совпадения ключей проверять подпись.
|
||||
|
||||
Если ключ не совпадает, сервер должен возвращать ошибку о том, что ключ не соответствует актуальной версии.
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "slogin-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
|
||||
"authenticatedAtMs": 1774600010500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Работа со списком сессий
|
||||
|
||||
### `ListSessions`
|
||||
|
||||
Доступно только после успешного `SessionLogin`.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ListSessions",
|
||||
"requestId": "list-001",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "list-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"clientInfo": "Android 15; Pixel 9",
|
||||
"lastAuthenticatedAtMs": 1774600010500,
|
||||
"createdAtMs": 1774600000201,
|
||||
"geo": "RU/Moscow"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `CloseActiveSession`
|
||||
|
||||
Доступно только после успешного `SessionLogin`.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CloseActiveSession",
|
||||
"requestId": "close-001",
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "close-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"closed": true,
|
||||
"sessionId": "sess_7c5e5c4b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Ошибки и коды отказа
|
||||
|
||||
Минимально стоит стандартизовать такие ответы:
|
||||
|
||||
- `400 BAD_REQUEST` — не хватает поля или неверный формат.
|
||||
- `401 UNAUTHORIZED` — challenge не был пройден или соединение не авторизовано.
|
||||
- `403 INVALID_SIGNATURE` — подпись не прошла проверку.
|
||||
- `403 DEVICE_KEY_NOT_ACTUAL` — присланный `deviceKey` не совпадает с актуальным ключом пользователя.
|
||||
- `403 SESSION_KEY_NOT_ACTUAL` — присланный `sessionKey` не совпадает с актуальным ключом сессии.
|
||||
- `404 SESSION_NOT_FOUND` — сессия не существует или уже закрыта.
|
||||
- `409 NONCE_ALREADY_USED` — challenge уже использован.
|
||||
- `410 CHALLENGE_EXPIRED` — nonce устарел.
|
||||
- `422 UNSUPPORTED_KEY_ALGORITHM` — префикс `session_key` не поддерживается сервером.
|
||||
- `429 TOO_MANY_ATTEMPTS` — лимит попыток исчерпан.
|
||||
|
||||
Пример:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "create-001",
|
||||
"status": 422,
|
||||
"error": "UNSUPPORTED_KEY_ALGORITHM",
|
||||
"message": "sessionKey prefix is not supported"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Совместимость с текущей реализацией сервера
|
||||
|
||||
По текущему состоянию кода сервер уже использует схему:
|
||||
|
||||
- `AuthChallenge(login)`
|
||||
- `CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo)`
|
||||
- `SessionChallenge(sessionId)`
|
||||
- `SessionLogin(sessionId, sessionKey, timeMs, signatureB64, clientInfo)`
|
||||
|
||||
Текущая строка подписи для `CreateAuthSession` в коде:
|
||||
|
||||
```text
|
||||
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
|
||||
```
|
||||
|
||||
Перед проверкой подписи сервер также должен сверять:
|
||||
|
||||
- `payload.deviceKey` с актуальным `solana_users.device_key`;
|
||||
- `payload.sessionKey` с актуальным `active_sessions.session_key`.
|
||||
|
||||
### Рекомендуемый путь миграции
|
||||
|
||||
1. Ввести новую версию контракта `CreateAuthSession`.
|
||||
2. На сервере хранить `session_key` целиком одной строкой.
|
||||
3. На сервере распознавать префикс алгоритма в `sessionKey`.
|
||||
4. В `CreateAuthSession` передавать и сверять `deviceKey`.
|
||||
5. В `SessionLogin` передавать и сверять `sessionKey`.
|
||||
6. Использовать подпись строки:
|
||||
|
||||
```text
|
||||
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Практические требования к клиентам
|
||||
|
||||
- Клиент должен сам хранить приватную часть `session_key`.
|
||||
- Приватная часть `device key` никогда не отправляется на сервер.
|
||||
- `session_key` должен быть новым для каждой новой сессии.
|
||||
- `storagePwd` должен генерироваться как криптографически стойкое значение.
|
||||
- Клиент должен учитывать допустимый дрейф времени и синхронизацию часов.
|
||||
- Клиент не должен повторно использовать старый `authNonce` или `nonce`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Короткое резюме
|
||||
|
||||
- Да, клиент сам создаёт `session_key`.
|
||||
- Да, клиент сам передаёт `storagePwd`.
|
||||
- Для `session_key` имеет смысл ввести префикс алгоритма, например `ed25519/...`.
|
||||
- Для `CreateAuthSession` клиент должен дополнительно передавать `deviceKey`, а сервер должен сверять его с актуальным ключом пользователя.
|
||||
- Для `SessionLogin` клиент должен дополнительно передавать `sessionKey`, а сервер должен сверять его с актуальным ключом сессии.
|
||||
- Для `CreateAuthSession` рекомендуется подписывать не только `login`, `timeMs` и `authNonce`, но также `sessionKey` и `storagePwd`.
|
||||
- Для разработчиков клиентов лучше сразу документировать API через полные JSON-примеры запросов и ответов.
|
||||
173
Dev_Docs/API/01_User_Registration_API.md
Normal file
173
Dev_Docs/API/01_User_Registration_API.md
Normal file
@ -0,0 +1,173 @@
|
||||
# API для разработчиков: Регистрация пользователя
|
||||
|
||||
Этот файл описывает временный раздел API, связанный с заведением пользователя на сервере и проверкой, существует ли пользователь.
|
||||
|
||||
Сейчас здесь два метода:
|
||||
|
||||
- `AddUser` — временная серверная регистрация пользователя;
|
||||
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных.
|
||||
|
||||
Их логика пока вспомогательная и dev-oriented: сервер сам хранит эти данные локально и сам отвечает на existence-check. В будущем оба сценария должны быть заменены на нормальную работу напрямую через Solana, но пока этот контракт нужен клиентам для разработки и интеграции.
|
||||
|
||||
## Статус документа
|
||||
|
||||
Это временная глава API.
|
||||
|
||||
Текущая регистрация пользователя и текущая проверка, существует пользователь или нет, пока реализованы как серверные dev/test операции. В будущем и регистрация, и проверка identity должны идти напрямую через Solana.
|
||||
|
||||
---
|
||||
|
||||
## 1. Операция `AddUser`
|
||||
|
||||
### Назначение
|
||||
|
||||
Временная регистрация локального пользователя на сервере.
|
||||
|
||||
Сервер:
|
||||
|
||||
- создаёт запись в `solana_users`;
|
||||
- создаёт стартовое состояние в `blockchain_state`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AddUser",
|
||||
"requestId": "reg-001",
|
||||
"payload": {
|
||||
"login": "anya",
|
||||
"blockchainName": "anya-001",
|
||||
"solanaKey": "BASE64_32_PUBLIC_KEY",
|
||||
"blockchainKey": "BASE64_32_PUBLIC_KEY",
|
||||
"deviceKey": "BASE64_32_PUBLIC_KEY",
|
||||
"bchLimit": 1000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AddUser",
|
||||
"requestId": "reg-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Пример ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AddUser",
|
||||
"requestId": "reg-001",
|
||||
"status": 409,
|
||||
"ok": false,
|
||||
"error": "USER_ALREADY_EXISTS",
|
||||
"message": "Пользователь с таким login уже существует",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `AddUser`
|
||||
|
||||
- `400 / BAD_FIELDS` — не переданы обязательные поля регистрации.
|
||||
- `400 / BAD_BLOCKCHAIN_NAME` — `blockchainName` не соответствует формату `<login>-NNN`.
|
||||
- `400 / BAD_KEY_FORMAT` — один из ключей не является корректным `Base64(32 bytes)`.
|
||||
- `409 / USER_ALREADY_EXISTS` — пользователь с таким `login` уже есть.
|
||||
- `409 / BLOCKCHAIN_ALREADY_EXISTS` — такой `blockchainName` уже занят.
|
||||
- `409 / BLOCKCHAIN_STATE_ALREADY_EXISTS` — стартовое состояние blockchain уже существует.
|
||||
- `501 / DB_ERROR` — ошибка БД при создании пользователя.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 2. Операция `GetUser`
|
||||
|
||||
### Назначение
|
||||
|
||||
Временная серверная проверка, существует пользователь или нет.
|
||||
|
||||
Важно:
|
||||
|
||||
- это временное решение;
|
||||
- позже клиент должен проверять existence/identity напрямую через Solana;
|
||||
- на финальный production flow не стоит жёстко завязывать архитектуру клиента на `GetUser`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetUser",
|
||||
"requestId": "user-001",
|
||||
"payload": {
|
||||
"login": "anya"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ: пользователь существует
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetUser",
|
||||
"requestId": "user-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"exists": true,
|
||||
"login": "Anya",
|
||||
"blockchainName": "anya-001",
|
||||
"solanaKey": "BASE64_32_PUBLIC_KEY",
|
||||
"blockchainKey": "BASE64_32_PUBLIC_KEY",
|
||||
"deviceKey": "BASE64_32_PUBLIC_KEY"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ: пользователя нет
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetUser",
|
||||
"requestId": "user-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"exists": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Пример ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetUser",
|
||||
"requestId": "user-001",
|
||||
"status": 400,
|
||||
"ok": false,
|
||||
"error": "BAD_FIELDS",
|
||||
"message": "Некорректные поля: login",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `GetUser`
|
||||
|
||||
- `400 / BAD_FIELDS` — не передан или пуст `login`.
|
||||
- `501 / DB_ERROR` — ошибка БД при поиске пользователя.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 3. Короткое резюме
|
||||
|
||||
- `AddUser` — временная регистрация пользователя на сервере.
|
||||
- `GetUser` — временная проверка существования пользователя на сервере.
|
||||
- И регистрация, и existence-check позже должны быть переведены на Solana.
|
||||
279
Dev_Docs/API/02_Authentication_API.md
Normal file
279
Dev_Docs/API/02_Authentication_API.md
Normal file
@ -0,0 +1,279 @@
|
||||
# API для разработчиков: Авторизация
|
||||
|
||||
Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую.
|
||||
|
||||
Здесь четыре метода:
|
||||
|
||||
- `AuthChallenge`
|
||||
- `CreateAuthSession`
|
||||
- `SessionChallenge`
|
||||
- `SessionLogin`
|
||||
|
||||
Логика раздела такая:
|
||||
|
||||
- сначала клиент либо начинает создание новой сессии через `deviceKey`;
|
||||
- либо начинает вход в уже созданную сессию через `sessionKey`;
|
||||
- сервер на первом шаге выдаёт challenge/nonce;
|
||||
- на втором шаге клиент присылает подписанный ответ;
|
||||
- сервер сверяет актуальные публичные ключи и только потом проверяет подпись.
|
||||
|
||||
Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов.
|
||||
|
||||
## 1. Поток авторизации
|
||||
|
||||
Поддерживаются два сценария:
|
||||
|
||||
1. Создание новой сессии:
|
||||
`AuthChallenge` -> `CreateAuthSession`
|
||||
2. Вход в существующую сессию:
|
||||
`SessionChallenge` -> `SessionLogin`
|
||||
|
||||
`deviceKey` используется для создания новой сессии.
|
||||
|
||||
`sessionKey` используется для входа в уже созданную сессию.
|
||||
|
||||
`sessionKey` передаётся и хранится целиком одной строкой, например:
|
||||
|
||||
```text
|
||||
ed25519/BASE64_PUBLIC_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `AuthChallenge`
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AuthChallenge",
|
||||
"requestId": "auth-001",
|
||||
"payload": {
|
||||
"login": "alice"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AuthChallenge",
|
||||
"requestId": "auth-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `AuthChallenge`
|
||||
|
||||
- `400 / EMPTY_LOGIN` — пустой `login`.
|
||||
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
|
||||
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
|
||||
|
||||
---
|
||||
|
||||
## 3. `CreateAuthSession`
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CreateAuthSession",
|
||||
"requestId": "create-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
|
||||
"timeMs": 1774600000123,
|
||||
"authNonce": "nonce",
|
||||
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
|
||||
"signatureB64": "BASE64_SIGNATURE",
|
||||
"clientInfo": "Android 15; Pixel 9"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Строка для подписи
|
||||
|
||||
```text
|
||||
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
|
||||
```
|
||||
|
||||
### Дополнительная проверка ключа
|
||||
|
||||
Перед проверкой подписи сервер должен:
|
||||
|
||||
1. взять актуальный `solana_users.device_key`;
|
||||
2. сравнить его с `payload.deviceKey`;
|
||||
3. только потом проверять подпись.
|
||||
|
||||
Если ключ не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`.
|
||||
|
||||
На будущее:
|
||||
|
||||
- для ротации `device_key` желательно добавить перепроверку через Solana.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CreateAuthSession",
|
||||
"requestId": "create-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `CreateAuthSession`
|
||||
|
||||
- `400 / NO_STEP1_CONTEXT` — для данного соединения не был корректно выполнен `AuthChallenge`.
|
||||
- `400 / EMPTY_LOGIN` — пустой `login`.
|
||||
- `400 / LOGIN_MISMATCH` — `login` не совпадает с тем, для кого был выдан `authNonce`.
|
||||
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при повторном чтении пользователя.
|
||||
- `422 / USER_NOT_FOUND` — пользователь не найден.
|
||||
- `501 / NO_LOGIN` — у пользователя на сервере не заполнен `login`.
|
||||
- `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`.
|
||||
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
|
||||
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `deviceKey` не поддерживается текущим сервером.
|
||||
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `deviceKey` или `signatureB64`.
|
||||
- `400 / EMPTY_SIGNATURE` — пустая подпись.
|
||||
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
|
||||
- `400 / NO_DEVICE_KEY` — у пользователя в БД отсутствует `deviceKey`.
|
||||
- `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`.
|
||||
- `400 / AUTH_NONCE_MISMATCH` — `authNonce` не соответствует значению из `AuthChallenge`.
|
||||
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`.
|
||||
- `422 / DEVICE_KEY_NOT_ACTUAL` — `deviceKey` не совпадает с актуальной версией на сервере.
|
||||
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
|
||||
- `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 4. `SessionChallenge`
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionChallenge",
|
||||
"requestId": "sch-001",
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionChallenge",
|
||||
"requestId": "sch-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"nonce": "0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `SessionChallenge`
|
||||
|
||||
- `400 / EMPTY_SESSION_ID` — пустой `sessionId`.
|
||||
- `501 / DB_ERROR` — ошибка БД при чтении сессии.
|
||||
- `422 / SESSION_NOT_FOUND` — сессия не найдена.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 5. `SessionLogin`
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionLogin",
|
||||
"requestId": "slogin-001",
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"timeMs": 1774600010456,
|
||||
"signatureB64": "BASE64_SIGNATURE",
|
||||
"clientInfo": "Android 15; Pixel 9"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Строка для подписи
|
||||
|
||||
```text
|
||||
SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
||||
```
|
||||
|
||||
### Дополнительная проверка ключа
|
||||
|
||||
Перед проверкой подписи сервер должен:
|
||||
|
||||
1. взять `active_sessions.session_key`;
|
||||
2. сравнить его с `payload.sessionKey`;
|
||||
3. только потом проверять подпись.
|
||||
|
||||
Если ключ не совпадает, сервер возвращает ошибку `SESSION_KEY_NOT_ACTUAL`.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionLogin",
|
||||
"requestId": "slogin-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `SessionLogin`
|
||||
|
||||
- `400 / EMPTY_SESSION_ID` — пустой `sessionId`.
|
||||
- `400 / NO_CHALLENGE` — перед `SessionLogin` не был успешно выполнен `SessionChallenge` либо nonce уже истёк.
|
||||
- `400 / SESSION_ID_MISMATCH` — nonce был выдан для другого `sessionId`.
|
||||
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
|
||||
- `400 / EMPTY_SIGNATURE` — пустая подпись.
|
||||
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
|
||||
- `501 / DB_ERROR` — ошибка БД при чтении сессии.
|
||||
- `422 / SESSION_NOT_FOUND` — сессия не найдена.
|
||||
- `501 / NO_SESSION_KEY` — у сессии отсутствует `session_key`.
|
||||
- `422 / SESSION_KEY_NOT_ACTUAL` — переданный `sessionKey` не совпадает с актуальной версией на сервере.
|
||||
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером.
|
||||
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`.
|
||||
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
|
||||
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии.
|
||||
- `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 6. Пример ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SessionLogin",
|
||||
"requestId": "slogin-001",
|
||||
"status": 403,
|
||||
"ok": false,
|
||||
"error": "SESSION_KEY_NOT_ACTUAL",
|
||||
"message": "session_key не соответствует актуальной версии",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
116
Dev_Docs/API/03_Session_Management_API.md
Normal file
116
Dev_Docs/API/03_Session_Management_API.md
Normal file
@ -0,0 +1,116 @@
|
||||
# API для разработчиков: Управление сессиями
|
||||
|
||||
Этот файл описывает методы, которые используются уже после успешной авторизации пользователя в сессию.
|
||||
|
||||
Здесь два метода:
|
||||
|
||||
- `ListSessions` — получить список активных сессий пользователя;
|
||||
- `CloseActiveSession` — закрыть одну из активных сессий.
|
||||
|
||||
Логика раздела такая:
|
||||
|
||||
- сначала пользователь проходит `SessionLogin`;
|
||||
- после этого сервер считает соединение авторизованным;
|
||||
- уже в этом состоянии клиент может читать список сессий и управлять ими.
|
||||
|
||||
То есть это не этап создания или входа в сессию, а этап последующего контроля уже существующих активных сессий.
|
||||
|
||||
## 1. `ListSessions`
|
||||
|
||||
Доступно только после успешного `SessionLogin`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ListSessions",
|
||||
"requestId": "list-001",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ListSessions",
|
||||
"requestId": "list-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"clientInfoFromClient": "Android 15; Pixel 9",
|
||||
"clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1",
|
||||
"geo": "RU/Moscow",
|
||||
"lastAuthenticatedAtMs": 1774600010500
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `ListSessions`
|
||||
|
||||
- `422 / NOT_AUTHENTICATED` — запрос доступен только после успешного `SessionLogin`.
|
||||
- `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 2. `CloseActiveSession`
|
||||
|
||||
Доступно только после успешного `SessionLogin`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CloseActiveSession",
|
||||
"requestId": "close-001",
|
||||
"payload": {
|
||||
"sessionId": "sess_7c5e5c4b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CloseActiveSession",
|
||||
"requestId": "close-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфические коды ошибок `CloseActiveSession`
|
||||
|
||||
- `422 / NOT_AUTHENTICATED` — запрос доступен только после успешного `SessionLogin`.
|
||||
- `400 / NO_SESSION_TO_CLOSE` — сервер не смог определить, какую сессию нужно закрыть.
|
||||
- `501 / DB_ERROR` — ошибка БД при поиске сессии или её удалении.
|
||||
- `422 / SESSION_NOT_FOUND` — целевая сессия не найдена.
|
||||
- `422 / SESSION_OF_ANOTHER_USER` — нельзя закрывать сессию другого пользователя.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
---
|
||||
|
||||
## 3. Пример ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CloseActiveSession",
|
||||
"requestId": "close-001",
|
||||
"status": 403,
|
||||
"ok": false,
|
||||
"error": "NOT_AUTHENTICATED",
|
||||
"message": "Операция доступна только для авторизованных пользователей",
|
||||
"payload": {
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -185,7 +185,10 @@ public final class JsonInboundProcessor {
|
||||
requestId,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_REQUEST_FORMAT",
|
||||
"Некорректный формат запроса: не удалось распарсить поля payload"
|
||||
NetExceptionResponseFactory.detailedMessage(
|
||||
"Некорректный формат запроса: не удалось распарсить поля payload",
|
||||
mapErr
|
||||
)
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
@ -216,7 +219,10 @@ public final class JsonInboundProcessor {
|
||||
requestId,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_HANDLER_ERROR",
|
||||
"Неожиданная ошибка при обработке операции: " + op
|
||||
NetExceptionResponseFactory.detailedMessage(
|
||||
"Неожиданная ошибка при обработке операции: " + op,
|
||||
handlerError
|
||||
)
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
@ -254,7 +260,7 @@ public final class JsonInboundProcessor {
|
||||
requestId,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера", e)
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
@ -281,6 +287,7 @@ public final class JsonInboundProcessor {
|
||||
* "op": ...,
|
||||
* "requestId": ...,
|
||||
* "status": ...,
|
||||
* "ok": true|false,
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*/
|
||||
@ -293,18 +300,39 @@ public final class JsonInboundProcessor {
|
||||
String op = full.hasNonNull("op") ? full.get("op").asText() : null;
|
||||
String requestId = full.hasNonNull("requestId") ? full.get("requestId").asText() : null;
|
||||
int status = full.hasNonNull("status") ? full.get("status").asInt() : 0;
|
||||
boolean ok = status >= 200 && status < 300;
|
||||
|
||||
String error = null;
|
||||
if (!ok) {
|
||||
if (full.hasNonNull("error")) error = full.get("error").asText();
|
||||
else if (full.hasNonNull("code")) error = full.get("code").asText();
|
||||
}
|
||||
|
||||
String message = null;
|
||||
if (!ok && full.hasNonNull("message")) {
|
||||
message = full.get("message").asText();
|
||||
}
|
||||
|
||||
// Удаляем базовые поля и payload из "полного" объекта,
|
||||
// всё остальное отправляем внутрь payload.
|
||||
full.remove("op");
|
||||
full.remove("requestId");
|
||||
full.remove("status");
|
||||
full.remove("ok");
|
||||
full.remove("error");
|
||||
full.remove("code");
|
||||
if (!ok) full.remove("message");
|
||||
full.remove("payload");
|
||||
|
||||
ObjectNode root = JSON_MAPPER.createObjectNode();
|
||||
if (op != null) root.put("op", op); else root.putNull("op");
|
||||
if (requestId != null) root.put("requestId", requestId); else root.putNull("requestId");
|
||||
root.put("status", status);
|
||||
root.put("ok", ok);
|
||||
if (!ok) {
|
||||
if (error != null) root.put("error", error); else root.putNull("error");
|
||||
if (message != null) root.put("message", message); else root.putNull("message");
|
||||
}
|
||||
|
||||
// payload — это всё, что осталось от full (может быть пустым объектом {})
|
||||
root.set("payload", full);
|
||||
@ -321,7 +349,10 @@ public final class JsonInboundProcessor {
|
||||
return "{\"op\":\"" + safe(response != null ? response.getOp() : null) +
|
||||
"\",\"requestId\":\"" + safe(response != null ? response.getRequestId() : null) +
|
||||
"\",\"status\":" + (response != null ? response.getStatus() : 500) +
|
||||
",\"payload\":{\"code\":\"SERIALIZATION_ERROR\",\"message\":\"Ошибка сериализации ответа\"}}";
|
||||
",\"ok\":false" +
|
||||
",\"error\":\"SERIALIZATION_ERROR\"" +
|
||||
",\"message\":\"Ошибка сериализации ответа\"" +
|
||||
",\"payload\":{}}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,8 @@ package server.logic.ws_protocol.JSON.entyties;
|
||||
/**
|
||||
* Ответ с ошибкой (любой отказ).
|
||||
*.
|
||||
* В payload будет:
|
||||
* {
|
||||
* "code": "...",
|
||||
* "message": "..."
|
||||
* }
|
||||
* В wire-формате error/message поднимаются на верхний уровень,
|
||||
* а payload остаётся объектом.
|
||||
*/
|
||||
public class Net_Exception_Response extends Net_Response {
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ package server.logic.ws_protocol.JSON.entyties;
|
||||
* "op": "...",
|
||||
* "requestId": "...",
|
||||
* "status": 200,
|
||||
* "ok": true,
|
||||
* "payload": { ... } // и для успеха, и для ошибки
|
||||
* }
|
||||
*/
|
||||
@ -29,6 +30,6 @@ public abstract class Net_Response extends Net_Request {
|
||||
}
|
||||
|
||||
public boolean isOk() {
|
||||
return status == 200;
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
||||
|
||||
public static final long ALLOWED_SKEW_MS = 30_000L;
|
||||
|
||||
@ -68,7 +69,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"NO_STEP1_CONTEXT",
|
||||
"Шаг 1 авторизации не был корректно выполнен для данного соединения"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no step1 context or bad auth state");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -82,7 +83,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"EMPTY_LOGIN",
|
||||
"Пустой login"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty login");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty login");
|
||||
return err;
|
||||
}
|
||||
if (!login.equals(loginFromContext)) {
|
||||
@ -92,7 +93,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"LOGIN_MISMATCH",
|
||||
"login не соответствует контексту AuthChallenge"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: login mismatch");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: login mismatch");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -106,7 +107,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"DB_ERROR_USER_LOOKUP",
|
||||
"Ошибка БД при получении пользователя"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db user lookup");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: db user lookup");
|
||||
return err;
|
||||
}
|
||||
if (user == null) {
|
||||
@ -116,7 +117,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"USER_NOT_FOUND",
|
||||
"Пользователь не найден"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: user not found");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: user not found");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -127,7 +128,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"NO_LOGIN",
|
||||
"Для пользователя не задан login в БД"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no login");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -139,7 +140,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"EMPTY_STORAGE_PWD",
|
||||
"Пустой storagePwd"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty storagePwd");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -151,7 +152,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"EMPTY_SESSION_KEY",
|
||||
"Пустой sessionKey"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session key");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty session key");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -165,7 +166,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"UNSUPPORTED_KEY_ALGORITHM",
|
||||
"sessionKey prefix is not supported"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: unsupported session key algorithm");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported session key algorithm");
|
||||
return err;
|
||||
} catch (IllegalArgumentException e) {
|
||||
Net_Response err = NetExceptionResponseFactory.error(
|
||||
@ -174,7 +175,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"BAD_BASE64",
|
||||
"Некорректный формат sessionKey"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session key format");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: bad session key format");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -186,7 +187,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"EMPTY_SIGNATURE",
|
||||
"Пустая цифровая подпись"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty signature");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -200,7 +201,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"TIME_SKEW",
|
||||
"Время клиента отличается от сервера более чем на 30 секунд"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: time skew");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -217,7 +218,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"NO_DEVICE_KEY",
|
||||
"Отсутствует deviceKey у пользователя"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no deviceKey");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -230,7 +231,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"EMPTY_AUTH_NONCE",
|
||||
"Пустой authNonce"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty authNonce");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty authNonce");
|
||||
return err;
|
||||
}
|
||||
if (!authNonce.equals(authNonceFromReq)) {
|
||||
@ -240,7 +241,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"AUTH_NONCE_MISMATCH",
|
||||
"authNonce не соответствует контексту AuthChallenge"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: authNonce mismatch");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: authNonce mismatch");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -252,7 +253,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"EMPTY_DEVICE_KEY",
|
||||
"Пустой deviceKey"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty deviceKey");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty deviceKey");
|
||||
return err;
|
||||
}
|
||||
deviceKeyFromReq = deviceKeyFromReq.trim();
|
||||
@ -265,7 +266,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"DEVICE_KEY_NOT_ACTUAL",
|
||||
"device_key не соответствует актуальной версии"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: device key mismatch");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: device key mismatch");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -287,7 +288,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"UNSUPPORTED_KEY_ALGORITHM",
|
||||
"deviceKey algorithm is not supported"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: unsupported device key algorithm");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported device key algorithm");
|
||||
return err;
|
||||
} catch (IllegalArgumentException ex) {
|
||||
Net_Response err = NetExceptionResponseFactory.error(
|
||||
@ -296,7 +297,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"BAD_BASE64",
|
||||
"Некорректный формат Base64 для ключа или подписи"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: bad base64");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -307,7 +308,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"BAD_SIGNATURE",
|
||||
"Подпись не прошла проверку"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: bad signature");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -364,7 +365,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
"DB_ERROR_SESSION_CREATE",
|
||||
"Ошибка БД при создании сессии"
|
||||
);
|
||||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error");
|
||||
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: db error");
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -414,4 +415,16 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
RANDOM.nextBytes(buf);
|
||||
return Base64Ws.encode(buf);
|
||||
}
|
||||
|
||||
private static void closeConnectionAfterErrorResponse(ConnectionContext ctx, int statusCode, String reason) {
|
||||
if (ctx == null) return;
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(CLOSE_AFTER_ERROR_DELAY_MS);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
WsConnectionUtils.closeConnection(ctx, statusCode, reason);
|
||||
}, "CreateAuthSessionClose-" + System.identityHashCode(ctx)).start();
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
|
||||
info.setSessionId(s.getSessionId());
|
||||
info.setClientInfoFromClient(s.getClientInfoFromClient());
|
||||
info.setClientInfoFromRequest(s.getClientInfoFromRequest());
|
||||
info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs());
|
||||
info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs());
|
||||
|
||||
String ip = s.getClientIp();
|
||||
String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip);
|
||||
|
||||
@ -17,7 +17,7 @@ import java.util.List;
|
||||
* "clientInfoFromClient": "...",
|
||||
* "clientInfoFromRequest": "...",
|
||||
* "geo": "Country, City" | "unknown",
|
||||
* "lastAuthirificatedAtMs": 1733310000000
|
||||
* "lastAuthenticatedAtMs": 1733310000000
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
@ -56,7 +56,7 @@ public class Net_ListSessions_Response extends Net_Response {
|
||||
private String geo;
|
||||
|
||||
/** Время последней успешной авторизации/refresh (мс с 1970-01-01). */
|
||||
private long lastAuthirificatedAtMs;
|
||||
private long lastAuthenticatedAtMs;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
@ -92,12 +92,12 @@ public class Net_ListSessions_Response extends Net_Response {
|
||||
this.geo = geo;
|
||||
}
|
||||
|
||||
public long getLastAuthirificatedAtMs() {
|
||||
return lastAuthirificatedAtMs;
|
||||
public long getLastAuthenticatedAtMs() {
|
||||
return lastAuthenticatedAtMs;
|
||||
}
|
||||
|
||||
public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) {
|
||||
this.lastAuthirificatedAtMs = lastAuthirificatedAtMs;
|
||||
public void setLastAuthenticatedAtMs(long lastAuthenticatedAtMs) {
|
||||
this.lastAuthenticatedAtMs = lastAuthenticatedAtMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -91,7 +91,7 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetFriendsLists", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,7 +178,7 @@ public class Net_AddUser_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при AddUser", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,7 +77,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetUser", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при SearchUsers", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetUserParam", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при ListUserParams", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +181,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
|
||||
req,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Внутренняя ошибка сервера"
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при UpsertUserParam", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
*/
|
||||
public final class NetExceptionResponseFactory {
|
||||
|
||||
private static final int MAX_DETAIL_LEN = 240;
|
||||
|
||||
private NetExceptionResponseFactory() {
|
||||
// запрет на создание объектов
|
||||
}
|
||||
@ -35,6 +37,39 @@ public final class NetExceptionResponseFactory {
|
||||
return resp;
|
||||
}
|
||||
|
||||
public static String detailedMessage(String prefix, Throwable error) {
|
||||
String safePrefix = prefix == null || prefix.isBlank()
|
||||
? "Внутренняя ошибка сервера"
|
||||
: prefix.trim();
|
||||
|
||||
if (error == null) {
|
||||
return safePrefix;
|
||||
}
|
||||
|
||||
String className = error.getClass().getSimpleName();
|
||||
if (className == null || className.isBlank()) {
|
||||
className = error.getClass().getName();
|
||||
}
|
||||
|
||||
String detail = error.getMessage();
|
||||
StringBuilder sb = new StringBuilder(safePrefix)
|
||||
.append(": ")
|
||||
.append(className);
|
||||
|
||||
if (detail != null && !detail.isBlank()) {
|
||||
sb.append(": ").append(detail.trim());
|
||||
}
|
||||
|
||||
String message = sb.toString()
|
||||
.replace('\n', ' ')
|
||||
.replace('\r', ' ');
|
||||
|
||||
if (message.length() <= MAX_DETAIL_LEN) {
|
||||
return message;
|
||||
}
|
||||
return message.substring(0, MAX_DETAIL_LEN - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Вариант для случаев, когда NetRequest ещё не распарсен,
|
||||
* но мы уже знаем op и requestId (или они null).
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package test.it.cases;
|
||||
|
||||
import test.it.utils.TestConfig;
|
||||
import test.it.utils.TestIds;
|
||||
import test.it.utils.json.JsonBuilders;
|
||||
import test.it.utils.json.JsonParsers;
|
||||
import test.it.utils.log.TestResult;
|
||||
@ -66,6 +67,8 @@ public class IT_01_AddUser {
|
||||
r.ok("SearchUsers: prefix(3)='" + prefix3Mixed + "' (должен вернуть список и содержать " + TestConfig.LOGIN() + ")");
|
||||
checkSearchUsersMustContain(r, ws, prefix3Mixed, TestConfig.LOGIN(), t);
|
||||
|
||||
checkNegativeRequests(r, ws, t);
|
||||
|
||||
} catch (Throwable e) {
|
||||
r.fail("IT_01_AddUser упал: " + e.getMessage());
|
||||
}
|
||||
@ -222,6 +225,74 @@ public class IT_01_AddUser {
|
||||
r.ok("SearchUsers: ok, prefix=" + prefix + ", results=" + logins.size() + ", contains=" + expectedLogin);
|
||||
}
|
||||
|
||||
private static void checkNegativeRequests(TestResult r, WsSession ws, Duration t) {
|
||||
String badAddUserReqId = TestIds.next("bad-adduser");
|
||||
String badAddUser = """
|
||||
{
|
||||
"op": "AddUser",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"login": "",
|
||||
"blockchainName": "%s",
|
||||
"solanaKey": "%s",
|
||||
"blockchainKey": "%s",
|
||||
"deviceKey": "%s",
|
||||
"bchLimit": %d
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
badAddUserReqId,
|
||||
TestConfig.BCH_NAME(),
|
||||
TestConfig.SOLANA_PUBKEY_B64(),
|
||||
TestConfig.BLOCKCHAIN_PUBKEY_B64(),
|
||||
TestConfig.DEVICE_PUBKEY_B64(),
|
||||
TestConfig.TEST_BCH_LIMIT
|
||||
);
|
||||
String badAddUserResp = ws.call("AddUser#NEGATIVE", badAddUser, t);
|
||||
assertErrorFormat(badAddUserResp, "AddUser", badAddUserReqId, "BAD_FIELDS");
|
||||
r.ok("Negative AddUser: error format OK");
|
||||
|
||||
String badGetUserReqId = TestIds.next("bad-getuser");
|
||||
String badGetUser = """
|
||||
{
|
||||
"op": "GetUser",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"login": ""
|
||||
}
|
||||
}
|
||||
""".formatted(badGetUserReqId);
|
||||
String badGetUserResp = ws.call("GetUser#NEGATIVE", badGetUser, t);
|
||||
assertErrorFormat(badGetUserResp, "GetUser", badGetUserReqId, "BAD_FIELDS");
|
||||
r.ok("Negative GetUser: error format OK");
|
||||
|
||||
String badSearchReqId = TestIds.next("bad-searchusers");
|
||||
String badSearch = """
|
||||
{
|
||||
"op": "SearchUsers",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"prefix": ""
|
||||
}
|
||||
}
|
||||
""".formatted(badSearchReqId);
|
||||
String badSearchResp = ws.call("SearchUsers#NEGATIVE", badSearch, t);
|
||||
assertErrorFormat(badSearchResp, "SearchUsers", badSearchReqId, "BAD_FIELDS");
|
||||
r.ok("Negative SearchUsers: error format OK");
|
||||
}
|
||||
|
||||
private static void assertErrorFormat(String resp, String op, String requestId, String code) {
|
||||
int status = JsonParsers.status(resp);
|
||||
if (status >= 200 && status < 300) fail("Expected non-2xx status: " + resp);
|
||||
if (!Boolean.FALSE.equals(JsonParsers.ok(resp))) fail("Expected ok=false: " + resp);
|
||||
if (!op.equals(JsonParsers.op(resp))) fail("Unexpected op: " + resp);
|
||||
if (!requestId.equals(JsonParsers.requestId(resp))) fail("Unexpected requestId: " + resp);
|
||||
if (!code.equals(JsonParsers.errorCode(resp))) fail("Unexpected error code: " + resp);
|
||||
if (!JsonParsers.payloadIsObject(resp)) fail("payload must be object: " + resp);
|
||||
if (JsonParsers.payloadSize(resp) != 0) fail("error payload must be empty object: " + resp);
|
||||
if (isBlank(JsonParsers.message(resp))) fail("error message must be present: " + resp);
|
||||
}
|
||||
|
||||
private static String canonicalLogin(String anyCaseLogin) {
|
||||
if (anyCaseLogin == null) return null;
|
||||
String x = anyCaseLogin.trim();
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
package test.it.cases;
|
||||
|
||||
import test.it.utils.TestConfig;
|
||||
import test.it.utils.TestIds;
|
||||
import test.it.utils.json.JsonBuilders;
|
||||
import test.it.utils.json.JsonParsers;
|
||||
import test.it.utils.log.TestLog;
|
||||
import test.it.utils.log.TestResult;
|
||||
import test.it.utils.ws.WsSession;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@ -54,6 +57,7 @@ public class IT_02_Sessions {
|
||||
|
||||
String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t);
|
||||
assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200");
|
||||
assertEquals(Boolean.TRUE, JsonParsers.ok(listResp), "ListSessions(AUTH_STATUS_USER) ok must be true");
|
||||
|
||||
List<String> ids = JsonParsers.sessionIds(listResp);
|
||||
r.ok("ListSessions(AUTH_STATUS_USER): " + ids);
|
||||
@ -82,6 +86,7 @@ public class IT_02_Sessions {
|
||||
|
||||
String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t);
|
||||
assertEquals(200, JsonParsers.status(listResp));
|
||||
assertEquals(Boolean.TRUE, JsonParsers.ok(listResp));
|
||||
|
||||
List<String> ids = JsonParsers.sessionIds(listResp);
|
||||
r.ok("Final ListSessions: " + ids);
|
||||
@ -93,6 +98,8 @@ public class IT_02_Sessions {
|
||||
r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)");
|
||||
}
|
||||
|
||||
checkNegativeRequests(t, r, s1);
|
||||
|
||||
} catch (Throwable e) {
|
||||
r.fail("IT_02_Sessions(v2) упал: " + e.getMessage());
|
||||
}
|
||||
@ -106,10 +113,11 @@ public class IT_02_Sessions {
|
||||
// шаг 1: AuthChallenge
|
||||
String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t);
|
||||
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200");
|
||||
assertEquals(Boolean.TRUE, JsonParsers.ok(nonceResp), "AuthChallenge(" + label + ") ok must be true");
|
||||
String authNonce = JsonParsers.authNonce(nonceResp);
|
||||
assertNotNull(authNonce, "authNonce must not be null for " + label);
|
||||
|
||||
String sessionKey = TestConfig.sessionKey(login);
|
||||
SessionMaterial sessionMaterial = newSessionMaterial();
|
||||
|
||||
// storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его)
|
||||
String storagePwd = TestConfig.fakeStoragePwd();
|
||||
@ -117,19 +125,18 @@ public class IT_02_Sessions {
|
||||
// шаг 2: CreateAuthSession (device подпись + deviceKey + sessionKey)
|
||||
String createResp = ws.call(
|
||||
"CreateAuthSession(" + label + ")",
|
||||
JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionKey),
|
||||
JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionMaterial.sessionKey()),
|
||||
t
|
||||
);
|
||||
assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200");
|
||||
assertEquals(Boolean.TRUE, JsonParsers.ok(createResp), "CreateAuthSession(" + label + ") ok must be true");
|
||||
|
||||
String sid = JsonParsers.sessionId(createResp);
|
||||
assertNotNull(sid, "sessionId must not be null");
|
||||
|
||||
r.ok("Создана сессия " + label + ": sessionId=" + sid);
|
||||
|
||||
byte[] sessionPrivKey = TestConfig.getSessionPrivatKey(login);
|
||||
|
||||
return new Session(sid, sessionKey, sessionPrivKey, storagePwd);
|
||||
return new Session(sid, sessionMaterial.sessionKey(), sessionMaterial.sessionPrivKey(), storagePwd);
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,12 +144,14 @@ public class IT_02_Sessions {
|
||||
// шаг 1: SessionChallenge(sessionId)
|
||||
String chResp = ws.call("SessionChallenge " + label, JsonBuilders.sessionChallenge(s.sessionId), t);
|
||||
assertEquals(200, JsonParsers.status(chResp), "SessionChallenge must be 200");
|
||||
assertEquals(Boolean.TRUE, JsonParsers.ok(chResp), "SessionChallenge ok must be true");
|
||||
String nonce = JsonParsers.sessionNonce(chResp);
|
||||
assertNotNull(nonce, "SessionChallenge nonce must not be null");
|
||||
|
||||
// шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...))
|
||||
String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, s.sessionKey, nonce, s.sessionPrivKey), t);
|
||||
assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200");
|
||||
assertEquals(Boolean.TRUE, JsonParsers.ok(loginResp), "SessionLogin ok must be true");
|
||||
|
||||
String storagePwd = JsonParsers.storagePwd(loginResp);
|
||||
assertNotNull(storagePwd, "storagePwd must not be null after SessionLogin");
|
||||
@ -151,5 +160,136 @@ public class IT_02_Sessions {
|
||||
r.ok(label + ": SessionLogin OK, storagePwd verified");
|
||||
}
|
||||
|
||||
private static void checkNegativeRequests(Duration t, TestResult r, Session session) {
|
||||
try (WsSession ws = WsSession.open()) {
|
||||
String reqId = TestIds.next("bad-authchallenge");
|
||||
String badReq = """
|
||||
{
|
||||
"op": "AuthChallenge",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"login": "NoSuchUser_123456"
|
||||
}
|
||||
}
|
||||
""".formatted(reqId);
|
||||
String resp = ws.call("AuthChallenge#NEGATIVE", badReq, t);
|
||||
assertErrorFormat(resp, "AuthChallenge", reqId, "UNKNOWN_USER");
|
||||
r.ok("Negative AuthChallenge: error format OK");
|
||||
}
|
||||
|
||||
try (WsSession ws = WsSession.open()) {
|
||||
String nonceResp = ws.call("AuthChallenge(NEG_CREATE)", JsonBuilders.authChallenge(LOGIN), t);
|
||||
assertEquals(200, JsonParsers.status(nonceResp));
|
||||
String authNonce = JsonParsers.authNonce(nonceResp);
|
||||
|
||||
SessionMaterial badSession = newSessionMaterial();
|
||||
String reqId = TestIds.next("bad-create");
|
||||
String badCreate = """
|
||||
{
|
||||
"op": "CreateAuthSession",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"login": "%s",
|
||||
"sessionKey": "%s",
|
||||
"storagePwd": "%s",
|
||||
"timeMs": %d,
|
||||
"authNonce": "%s",
|
||||
"deviceKey": "%s",
|
||||
"signatureB64": "%s",
|
||||
"clientInfo": "%s"
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
reqId,
|
||||
LOGIN,
|
||||
badSession.sessionKey(),
|
||||
TestConfig.fakeStoragePwd(),
|
||||
System.currentTimeMillis(),
|
||||
authNonce,
|
||||
"WRONG_DEVICE_KEY",
|
||||
"AAAA",
|
||||
TestConfig.TEST_CLIENT_INFO
|
||||
);
|
||||
String resp = ws.call("CreateAuthSession#NEGATIVE", badCreate, t);
|
||||
assertErrorFormat(resp, "CreateAuthSession", reqId, "DEVICE_KEY_NOT_ACTUAL");
|
||||
r.ok("Negative CreateAuthSession: error format OK");
|
||||
}
|
||||
|
||||
try (WsSession ws = WsSession.open()) {
|
||||
String reqId = TestIds.next("bad-sessionchallenge");
|
||||
String badReq = """
|
||||
{
|
||||
"op": "SessionChallenge",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"sessionId": "missing-session-id"
|
||||
}
|
||||
}
|
||||
""".formatted(reqId);
|
||||
String resp = ws.call("SessionChallenge#NEGATIVE", badReq, t);
|
||||
assertErrorFormat(resp, "SessionChallenge", reqId, "SESSION_NOT_FOUND");
|
||||
r.ok("Negative SessionChallenge: error format OK");
|
||||
}
|
||||
|
||||
try (WsSession ws = WsSession.open()) {
|
||||
String chResp = ws.call("SessionChallenge NEG_LOGIN", JsonBuilders.sessionChallenge(session.sessionId), t);
|
||||
assertEquals(200, JsonParsers.status(chResp));
|
||||
String nonce = JsonParsers.sessionNonce(chResp);
|
||||
|
||||
SessionMaterial wrongSession = newSessionMaterial();
|
||||
long timeMs = System.currentTimeMillis();
|
||||
String signatureB64 = JsonBuilders.signSessionLogin(session.sessionId, timeMs, nonce, wrongSession.sessionPrivKey());
|
||||
String reqId = TestIds.next("bad-sessionlogin");
|
||||
String badLoginReq = """
|
||||
{
|
||||
"op": "SessionLogin",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"sessionId": "%s",
|
||||
"sessionKey": "%s",
|
||||
"timeMs": %d,
|
||||
"signatureB64": "%s",
|
||||
"clientInfo": "%s"
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
reqId,
|
||||
session.sessionId,
|
||||
wrongSession.sessionKey(),
|
||||
timeMs,
|
||||
signatureB64,
|
||||
TestConfig.TEST_CLIENT_INFO
|
||||
);
|
||||
String badLoginResp = ws.call("SessionLogin#NEGATIVE", badLoginReq, t);
|
||||
assertErrorFormat(
|
||||
badLoginResp,
|
||||
"SessionLogin",
|
||||
reqId,
|
||||
"SESSION_KEY_NOT_ACTUAL"
|
||||
);
|
||||
r.ok("Negative SessionLogin: error format OK");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertErrorFormat(String resp, String op, String requestId, String code) {
|
||||
int status = JsonParsers.status(resp);
|
||||
assertFalse(status >= 200 && status < 300, "Expected non-2xx status: " + resp);
|
||||
assertEquals(Boolean.FALSE, JsonParsers.ok(resp), "Expected ok=false: " + resp);
|
||||
assertEquals(op, JsonParsers.op(resp), "Unexpected op: " + resp);
|
||||
assertEquals(requestId, JsonParsers.requestId(resp), "Unexpected requestId: " + resp);
|
||||
assertEquals(code, JsonParsers.errorCode(resp), "Unexpected error code: " + resp);
|
||||
assertTrue(JsonParsers.payloadIsObject(resp), "payload must be object: " + resp);
|
||||
assertEquals(0, JsonParsers.payloadSize(resp), "error payload must be empty object: " + resp);
|
||||
assertNotNull(JsonParsers.message(resp), "message must be present: " + resp);
|
||||
}
|
||||
|
||||
private static SessionMaterial newSessionMaterial() {
|
||||
byte[] sessionPrivKey = Ed25519Util.generatePrivateKey();
|
||||
byte[] sessionPubKey = Ed25519Util.derivePublicKey(sessionPrivKey);
|
||||
String sessionKey = "ed25519/" + Base64.getEncoder().encodeToString(sessionPubKey);
|
||||
return new SessionMaterial(sessionKey, sessionPrivKey);
|
||||
}
|
||||
|
||||
private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {}
|
||||
private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {}
|
||||
}
|
||||
|
||||
@ -19,6 +19,62 @@ public final class JsonParsers {
|
||||
}
|
||||
}
|
||||
|
||||
public static String op(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
return root.has("op") && !root.get("op").isNull() ? root.get("op").asText() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String requestId(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
return root.has("requestId") && !root.get("requestId").isNull() ? root.get("requestId").asText() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Boolean ok(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
return root.has("ok") ? root.get("ok").asBoolean() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String message(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
return root.has("message") && !root.get("message").isNull() ? root.get("message").asText() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean payloadIsObject(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
JsonNode payload = root.get("payload");
|
||||
return payload != null && payload.isObject();
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static int payloadSize(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
JsonNode payload = root.get("payload");
|
||||
return payload != null && payload.isObject() ? payload.size() : -1;
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static String authNonce(String json) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
@ -97,6 +153,7 @@ public final class JsonParsers {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
|
||||
if (root.has("error")) return root.get("error").asText();
|
||||
// поддержка старого формата (верхний уровень)
|
||||
if (root.has("errorCode")) return root.get("errorCode").asText();
|
||||
// поддержка нового формата (верхний уровень)
|
||||
@ -106,6 +163,7 @@ public final class JsonParsers {
|
||||
if (payload != null) {
|
||||
// поддержка старого формата (внутри payload)
|
||||
if (payload.has("errorCode")) return payload.get("errorCode").asText();
|
||||
if (payload.has("error")) return payload.get("error").asText();
|
||||
// поддержка нового формата (внутри payload)
|
||||
if (payload.has("code")) return payload.get("code").asText();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user