diff --git a/Dev_Docs/00_INDEX.md b/Dev_Docs/00_INDEX.md index 694e7fd..1d6d246 100644 --- a/Dev_Docs/00_INDEX.md +++ b/Dev_Docs/00_INDEX.md @@ -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, авторизация (двухшаговая), создание сессии, вход в существующую сессию, просмотр и закрытие сессий. diff --git a/Dev_Docs/API/00_Common_API_Format.md b/Dev_Docs/API/00_Common_API_Format.md new file mode 100644 index 0000000..5b79c88 --- /dev/null +++ b/Dev_Docs/API/00_Common_API_Format.md @@ -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: {}`. diff --git a/Dev_Docs/API/01_Auth_and_Sessions_API.md b/Dev_Docs/API/01_Auth_and_Sessions_API.md deleted file mode 100644 index b372389..0000000 --- a/Dev_Docs/API/01_Auth_and_Sessions_API.md +++ /dev/null @@ -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 -/ -``` - -Примеры: - -```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-примеры запросов и ответов. diff --git a/Dev_Docs/API/01_User_Registration_API.md b/Dev_Docs/API/01_User_Registration_API.md new file mode 100644 index 0000000..1e0e070 --- /dev/null +++ b/Dev_Docs/API/01_User_Registration_API.md @@ -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` не соответствует формату `-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. diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md new file mode 100644 index 0000000..93acc2d --- /dev/null +++ b/Dev_Docs/API/02_Authentication_API.md @@ -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": { + } +} +``` diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md new file mode 100644 index 0000000..2792637 --- /dev/null +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -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": { + } +} +``` diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java index 90ca96a..dcba0cf 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java @@ -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\":{}}"; } } @@ -345,4 +376,4 @@ public final class JsonInboundProcessor { return String.valueOf(o); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Exception_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Exception_Response.java index 1c4f1d8..452873f 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Exception_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Exception_Response.java @@ -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 { diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Response.java index 0cd3055..b76bbd1 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Net_Response.java @@ -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; } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index a25a972..6241f38 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -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(); + } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index 9569d2b..50e73b9 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -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); @@ -83,4 +83,4 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { return resp; } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java index 414a817..08219d1 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java @@ -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; } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java index 5aee04d..c105b9e 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java @@ -91,7 +91,7 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetFriendsLists", e) ); } } @@ -111,4 +111,4 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { } } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java index 8b56632..ff545c3 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java @@ -178,8 +178,8 @@ public class Net_AddUser_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при AddUser", e) ); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index dc97598..58f49b7 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -77,8 +77,8 @@ public class Net_GetUser_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetUser", e) ); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java index f1abfd7..6647fea 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java @@ -70,8 +70,8 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при SearchUsers", e) ); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java index 1a88713..6179402 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java @@ -83,8 +83,8 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetUserParam", e) ); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java index 46b3c47..b97584c 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java @@ -84,8 +84,8 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при ListUserParams", e) ); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java index 5f1b179..1b4b019 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java @@ -181,8 +181,8 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", - "Внутренняя ошибка сервера" + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при UpsertUserParam", e) ); } } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetExceptionResponseFactory.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetExceptionResponseFactory.java index fb38935..565098f 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetExceptionResponseFactory.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetExceptionResponseFactory.java @@ -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). @@ -53,4 +88,4 @@ public final class NetExceptionResponseFactory { resp.setMessage(message); return resp; } -} \ No newline at end of file +} diff --git a/src/test/java/test/it/cases/IT_01_AddUser.java b/src/test/java/test/it/cases/IT_01_AddUser.java index d2ce156..d05335e 100644 --- a/src/test/java/test/it/cases/IT_01_AddUser.java +++ b/src/test/java/test/it/cases/IT_01_AddUser.java @@ -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(); @@ -253,4 +324,4 @@ public class IT_01_AddUser { private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } -} \ No newline at end of file +} diff --git a/src/test/java/test/it/cases/IT_02_Sessions.java b/src/test/java/test/it/cases/IT_02_Sessions.java index 8fe426b..27da4fc 100644 --- a/src/test/java/test/it/cases/IT_02_Sessions.java +++ b/src/test/java/test/it/cases/IT_02_Sessions.java @@ -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 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 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) {} } diff --git a/src/test/java/test/it/utils/json/JsonParsers.java b/src/test/java/test/it/utils/json/JsonParsers.java index 1eb1e5a..ca10cbc 100644 --- a/src/test/java/test/it/utils/json/JsonParsers.java +++ b/src/test/java/test/it/utils/json/JsonParsers.java @@ -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(); } @@ -214,4 +272,4 @@ public final class JsonParsers { return null; } } -} \ No newline at end of file +}