Compare commits
2 Commits
5d13112b00
...
b166013707
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
b166013707 | ||
|
|
3e04727022 |
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую.
|
Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую.
|
||||||
|
|
||||||
Здесь четыре метода:
|
Здесь четыре базовых метода обычной авторизации:
|
||||||
|
|
||||||
- `AuthChallenge`
|
- `AuthChallenge`
|
||||||
- `CreateAuthSession`
|
- `CreateAuthSession`
|
||||||
@ -36,6 +36,16 @@
|
|||||||
|
|
||||||
Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов.
|
Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов.
|
||||||
|
|
||||||
|
Отдельно появился новый серверный сценарий pairing через доверенный homeserver/ESP. Он не заменяет обычный вход и описан в:
|
||||||
|
|
||||||
|
- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md`
|
||||||
|
|
||||||
|
Кратко:
|
||||||
|
|
||||||
|
- `AuthChallenge/CreateAuthSession` и `SessionChallenge/SessionLogin` остаются каноническими потоками обычной авторизации;
|
||||||
|
- pairing через ESP идёт отдельными `op` и только подготавливает безопасное добавление новой сессии;
|
||||||
|
- решение об одобрении pairing принимает любая уже авторизованная доверенная сессия пользователя.
|
||||||
|
|
||||||
## 1. Поток авторизации
|
## 1. Поток авторизации
|
||||||
|
|
||||||
Поддерживаются два сценария:
|
Поддерживаются два сценария:
|
||||||
@ -289,6 +299,30 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6. Pairing через homeserver/ESP
|
||||||
|
|
||||||
|
Новые `op`, относящиеся к этому сценарию:
|
||||||
|
|
||||||
|
- `UpsertEspPairingSettings`
|
||||||
|
- `StartEspPairing`
|
||||||
|
- `ListEspPairingRequests`
|
||||||
|
- `ApproveEspPairing`
|
||||||
|
- `RejectEspPairing`
|
||||||
|
- `GetEspPairingStatus`
|
||||||
|
|
||||||
|
В этом потоке:
|
||||||
|
|
||||||
|
- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`;
|
||||||
|
- пароль проверяется сервером только как фильтр;
|
||||||
|
- решение об одобрении принимает уже авторизованная доверенная сессия пользователя;
|
||||||
|
- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей.
|
||||||
|
|
||||||
|
Точные форматы этих операций см. в `03_Session_Management_API.md` и в протокольном документе:
|
||||||
|
|
||||||
|
- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 6. Пример ошибки
|
## 6. Пример ошибки
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -7,6 +7,18 @@
|
|||||||
- `ListSessions` — получить список активных сессий пользователя;
|
- `ListSessions` — получить список активных сессий пользователя;
|
||||||
- `CloseActiveSession` — закрыть одну из активных сессий.
|
- `CloseActiveSession` — закрыть одну из активных сессий.
|
||||||
|
|
||||||
|
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
|
||||||
|
|
||||||
|
- `UpsertEspPairingSettings`
|
||||||
|
- `ListEspPairingRequests`
|
||||||
|
- `ApproveEspPairing`
|
||||||
|
- `RejectEspPairing`
|
||||||
|
|
||||||
|
Анонимное новое устройство работает с двумя связанными операциями:
|
||||||
|
|
||||||
|
- `StartEspPairing`
|
||||||
|
- `GetEspPairingStatus`
|
||||||
|
|
||||||
Логика раздела такая:
|
Логика раздела такая:
|
||||||
|
|
||||||
- сначала пользователь проходит `SessionLogin`;
|
- сначала пользователь проходит `SessionLogin`;
|
||||||
@ -151,3 +163,226 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор.
|
Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор.
|
||||||
Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON.
|
Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ESP pairing через доверенную сессию
|
||||||
|
|
||||||
|
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
|
||||||
|
|
||||||
|
### 5.1. `UpsertEspPairingSettings`
|
||||||
|
|
||||||
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "UpsertEspPairingSettings",
|
||||||
|
"requestId": "esp-set-001",
|
||||||
|
"payload": {
|
||||||
|
"enabled": true,
|
||||||
|
"passwordHash": "argon2id$...",
|
||||||
|
"ttlSeconds": 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "UpsertEspPairingSettings",
|
||||||
|
"requestId": "esp-set-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"enabled": true,
|
||||||
|
"ttlSeconds": 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`.
|
||||||
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
|
### 5.2. `StartEspPairing`
|
||||||
|
|
||||||
|
Эта операция доступна без уже существующей пользовательской сессии.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "StartEspPairing",
|
||||||
|
"requestId": "esp-start-001",
|
||||||
|
"payload": {
|
||||||
|
"login": "alice",
|
||||||
|
"passwordHash": "argon2id$...",
|
||||||
|
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||||
|
"requesterSessionType": 1,
|
||||||
|
"requesterClientPlatform": "Android",
|
||||||
|
"payloadType": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "StartEspPairing",
|
||||||
|
"requestId": "esp-start-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"state": "created",
|
||||||
|
"shortCode": "4920709",
|
||||||
|
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
||||||
|
"expiresAtMs": 1781441990538,
|
||||||
|
"trustedSessionOnline": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `400 / EMPTY_LOGIN`
|
||||||
|
- `400 / EMPTY_PASSWORD_HASH`
|
||||||
|
- `400 / EMPTY_REQUESTER_SESSION_KEY`
|
||||||
|
- `400 / BAD_REQUESTER_SESSION_KEY`
|
||||||
|
- `400 / BAD_SESSION_TYPE`
|
||||||
|
- `400 / BAD_PAYLOAD_TYPE`
|
||||||
|
- `422 / PAIRING_NOT_AVAILABLE`
|
||||||
|
- `422 / PAIRING_PASSWORD_INVALID`
|
||||||
|
- `429 / PAIRING_RATE_LIMITED`
|
||||||
|
|
||||||
|
### 5.3. `ListEspPairingRequests`
|
||||||
|
|
||||||
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ListEspPairingRequests",
|
||||||
|
"requestId": "esp-list-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"state": "created",
|
||||||
|
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||||
|
"requesterSessionType": 1,
|
||||||
|
"requesterClientPlatform": "Android",
|
||||||
|
"payloadType": 1,
|
||||||
|
"shortCode": "4920709",
|
||||||
|
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
||||||
|
"createdAtMs": 1781441810538,
|
||||||
|
"expiresAtMs": 1781441990538,
|
||||||
|
"deliveredToHomeserver": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
||||||
|
|
||||||
|
### 5.4. `ApproveEspPairing`
|
||||||
|
|
||||||
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ApproveEspPairing",
|
||||||
|
"requestId": "esp-approve-001",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"encryptedPayload": "BASE64_OR_OTHER_OPAQUE_PAYLOAD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ApproveEspPairing",
|
||||||
|
"requestId": "esp-approve-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"state": "approved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `400 / EMPTY_PAIRING_ID`
|
||||||
|
- `400 / EMPTY_ENCRYPTED_PAYLOAD`
|
||||||
|
- `404 / PAIRING_NOT_FOUND`
|
||||||
|
- `422 / PAIRING_OF_ANOTHER_USER`
|
||||||
|
- `422 / PAIRING_NOT_PENDING`
|
||||||
|
- `422 / PAIRING_EXPIRED`
|
||||||
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
||||||
|
|
||||||
|
### 5.5. `RejectEspPairing`
|
||||||
|
|
||||||
|
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
|
||||||
|
|
||||||
|
### 5.6. `GetEspPairingStatus`
|
||||||
|
|
||||||
|
Операция для нового устройства.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetEspPairingStatus",
|
||||||
|
"requestId": "esp-status-001",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Успешный ответ после approve
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetEspPairingStatus",
|
||||||
|
"requestId": "esp-status-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"state": "approved",
|
||||||
|
"shortCode": "4920709",
|
||||||
|
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
||||||
|
"payloadType": 1,
|
||||||
|
"encryptedPayload": "AQIDBA==",
|
||||||
|
"expiresAtMs": 1781441990538
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Возможные `state`
|
||||||
|
|
||||||
|
- `created`
|
||||||
|
- `approved`
|
||||||
|
- `rejected`
|
||||||
|
- `expired`
|
||||||
|
|||||||
@ -19,6 +19,12 @@
|
|||||||
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
||||||
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
||||||
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
|
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
|
||||||
|
| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
|
||||||
|
| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
|
||||||
|
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
||||||
|
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
||||||
|
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
||||||
|
| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
|
||||||
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
|
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
|
||||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# server esp pairing
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- на сервере добавлен отдельный pairing-сценарий для подключения нового устройства через доверенную уже авторизованную сессию пользователя без выдачи приватных ключей сервером;
|
||||||
|
- добавлены `op`: `UpsertEspPairingSettings`, `StartEspPairing`, `ListEspPairingRequests`, `ApproveEspPairing`, `RejectEspPairing`, `GetEspPairingStatus`;
|
||||||
|
- подтверждать pairing может любая доверенная сессия пользователя.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- любая уже авторизованная сессия пользователя включает pairing и задаёт `passwordHash`;
|
||||||
|
- новое устройство создаёт заявку через `StartEspPairing`;
|
||||||
|
- другая доверенная сессия пользователя видит заявку в `ListEspPairingRequests`;
|
||||||
|
- доверенная сессия подтверждает заявку через `ApproveEspPairing`;
|
||||||
|
- новое устройство получает `approved + encryptedPayload` через `GetEspPairingStatus`;
|
||||||
|
- неавторизованное новое устройство не может вызывать управляющие pairing-операции.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- заявка создаётся со статусом `created`;
|
||||||
|
- у заявки есть `pairingId`, `shortCode`, `fingerprintB58`, `expiresAtMs`;
|
||||||
|
- после approve статус становится `approved`, а `encryptedPayload` возвращается новому устройству;
|
||||||
|
- неавторизованное соединение получает отказ `463 / PAIRING_REQUIRES_AUTH_SESSION`.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- `pending`
|
||||||
106
Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md
Normal file
106
Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# ESP Pairing и режимы подключения
|
||||||
|
|
||||||
|
Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя.
|
||||||
|
|
||||||
|
## 1. Текущие режимы
|
||||||
|
|
||||||
|
### 1. Создание новой сессии через `deviceKey`
|
||||||
|
|
||||||
|
Поток:
|
||||||
|
|
||||||
|
`AuthChallenge -> CreateAuthSession`
|
||||||
|
|
||||||
|
Смысл:
|
||||||
|
|
||||||
|
- новое устройство уже владеет приватным `deviceKey`;
|
||||||
|
- сервер проверяет подпись `deviceKey`;
|
||||||
|
- создаётся обычная активная сессия пользователя;
|
||||||
|
- этот поток остаётся без изменений.
|
||||||
|
|
||||||
|
### 2. Повторный вход в существующую сессию через `sessionKey`
|
||||||
|
|
||||||
|
Поток:
|
||||||
|
|
||||||
|
`SessionChallenge -> SessionLogin`
|
||||||
|
|
||||||
|
Смысл:
|
||||||
|
|
||||||
|
- устройство уже владеет приватным `sessionKey`;
|
||||||
|
- сервер проверяет подпись `sessionKey`;
|
||||||
|
- соединение снова входит в существующую сессию;
|
||||||
|
- этот поток тоже остаётся без изменений.
|
||||||
|
|
||||||
|
## 2. Новый режим: добавление сессии через доверенное устройство пользователя
|
||||||
|
|
||||||
|
Новый поток не заменяет обычный логин, а живёт рядом с ним.
|
||||||
|
|
||||||
|
Цель:
|
||||||
|
|
||||||
|
- новое устройство знает `login + pairing password`;
|
||||||
|
- сервер использует пароль только как фильтр от мусора;
|
||||||
|
- реальное доверие даёт любая уже онлайн доверенная сессия пользователя;
|
||||||
|
- сервер не выдаёт приватные ключи сам от себя.
|
||||||
|
|
||||||
|
Поток версии `v1`:
|
||||||
|
|
||||||
|
1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку:
|
||||||
|
`UpsertEspPairingSettings`
|
||||||
|
2. Новое устройство создаёт pending-заявку:
|
||||||
|
`StartEspPairing`
|
||||||
|
3. Онлайн доверенная сессия видит список активных заявок:
|
||||||
|
`ListEspPairingRequests`
|
||||||
|
4. Доверенная сессия либо подтверждает заявку:
|
||||||
|
`ApproveEspPairing`
|
||||||
|
5. Либо отклоняет:
|
||||||
|
`RejectEspPairing`
|
||||||
|
6. Новое устройство читает результат:
|
||||||
|
`GetEspPairingStatus`
|
||||||
|
|
||||||
|
## 3. Что именно делает сервер
|
||||||
|
|
||||||
|
- хранит включённость pairing и opaque `passwordHash`;
|
||||||
|
- хранит pending/approved/rejected pairing-заявки;
|
||||||
|
- рассчитывает короткий код `shortCode` из `7` цифр;
|
||||||
|
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
||||||
|
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||||
|
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
||||||
|
|
||||||
|
## 4. Чего сервер в этой версии не делает
|
||||||
|
|
||||||
|
- не передаёт приватный `deviceKey`;
|
||||||
|
- не расшифровывает `encryptedPayload`;
|
||||||
|
- не проверяет криптографию содержимого payload;
|
||||||
|
- не делает клиентский UI;
|
||||||
|
- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера.
|
||||||
|
|
||||||
|
Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах.
|
||||||
|
|
||||||
|
## 5. Роли и ограничения
|
||||||
|
|
||||||
|
- любая уже авторизованная доверенная сессия пользователя может вызывать:
|
||||||
|
- `UpsertEspPairingSettings`
|
||||||
|
- `ListEspPairingRequests`
|
||||||
|
- `ApproveEspPairing`
|
||||||
|
- `RejectEspPairing`
|
||||||
|
- новое устройство может вызвать `StartEspPairing` и `GetEspPairingStatus` без уже существующей авторизованной сессии;
|
||||||
|
- `payloadType` поддерживается в вариантах:
|
||||||
|
- `1` — минимальный пакет
|
||||||
|
- `2` — расширенный пакет
|
||||||
|
- `3` — полный пакет
|
||||||
|
|
||||||
|
Сервер не интерпретирует эти три типа глубже, а только фиксирует их в состоянии заявки.
|
||||||
|
|
||||||
|
## 6. Статусы pairing-заявки
|
||||||
|
|
||||||
|
- `created` — заявка создана и ждёт решения доверенной сессии;
|
||||||
|
- `approved` — доверенная сессия подтвердила и приложила `encryptedPayload`;
|
||||||
|
- `rejected` — доверенная сессия отклонила заявку;
|
||||||
|
- `expired` — TTL заявки истёк до подтверждения.
|
||||||
|
|
||||||
|
## 7. Практический смысл
|
||||||
|
|
||||||
|
Эта схема даёт нужное разделение доверия:
|
||||||
|
|
||||||
|
- пароль на сервере только отсеивает лишних;
|
||||||
|
- онлайн доверенная сессия решает, добавлять ли новую сессию;
|
||||||
|
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
|
||||||
@ -656,13 +656,33 @@ class ShinePyBotService:
|
|||||||
self.state["current_history_file"] = str(history_file)
|
self.state["current_history_file"] = str(history_file)
|
||||||
self._persist_state()
|
self._persist_state()
|
||||||
|
|
||||||
def _current_history_file_for_user(self, username: str) -> Path:
|
def _user_session_state(self, username: str) -> dict[str, Any]:
|
||||||
uname = normalize_username(username) or self.cfg.allowed_username
|
uname = normalize_username(username) or self.cfg.allowed_username
|
||||||
self._ensure_user_session(uname)
|
self._ensure_user_session(uname)
|
||||||
sessions = self.state.get("user_sessions") or {}
|
sessions = self.state.get("user_sessions") or {}
|
||||||
session = sessions.get(uname) or {}
|
session = sessions.get(uname)
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
session = {}
|
||||||
|
sessions[uname] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _current_history_file_for_user(self, username: str) -> Path:
|
||||||
|
session = self._user_session_state(username)
|
||||||
return Path(session["current_history_file"])
|
return Path(session["current_history_file"])
|
||||||
|
|
||||||
|
def _codex_thread_id_for_user(self, username: str) -> str:
|
||||||
|
thread_id = (self._user_session_state(username).get("codex_thread_id") or "").strip()
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
def _set_codex_thread_id_for_user(self, username: str, thread_id: str) -> None:
|
||||||
|
session = self._user_session_state(username)
|
||||||
|
normalized = (thread_id or "").strip()
|
||||||
|
if normalized:
|
||||||
|
session["codex_thread_id"] = normalized
|
||||||
|
else:
|
||||||
|
session.pop("codex_thread_id", None)
|
||||||
|
self._persist_state()
|
||||||
|
|
||||||
def _create_new_history_file(self, reason: str, username: str) -> Path:
|
def _create_new_history_file(self, reason: str, username: str) -> Path:
|
||||||
ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||||
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
|
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
@ -690,7 +710,12 @@ class ShinePyBotService:
|
|||||||
if not isinstance(sessions, dict):
|
if not isinstance(sessions, dict):
|
||||||
sessions = {}
|
sessions = {}
|
||||||
self.state["user_sessions"] = sessions
|
self.state["user_sessions"] = sessions
|
||||||
|
previous = sessions.get(uname) if isinstance(sessions.get(uname), dict) else {}
|
||||||
sessions[uname] = {"current_history_file": str(new_file)}
|
sessions[uname] = {"current_history_file": str(new_file)}
|
||||||
|
if reason != "command_new" and isinstance(previous, dict):
|
||||||
|
thread_id = (previous.get("codex_thread_id") or "").strip()
|
||||||
|
if thread_id:
|
||||||
|
sessions[uname]["codex_thread_id"] = thread_id
|
||||||
if uname == self.cfg.allowed_username:
|
if uname == self.cfg.allowed_username:
|
||||||
self.state["current_history_file"] = str(new_file)
|
self.state["current_history_file"] = str(new_file)
|
||||||
self._persist_state()
|
self._persist_state()
|
||||||
@ -926,7 +951,7 @@ class ShinePyBotService:
|
|||||||
text = (
|
text = (
|
||||||
f"Привет, {player_name}.\n"
|
f"Привет, {player_name}.\n"
|
||||||
"Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n"
|
"Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n"
|
||||||
"Команда /new начинает новую сессию и архивирует текущую историю."
|
"Команда /new начинает новую Codex-сессию и архивирует текущую историю."
|
||||||
)
|
)
|
||||||
reminder = self._task_center_counts_text(uname)
|
reminder = self._task_center_counts_text(uname)
|
||||||
if reminder:
|
if reminder:
|
||||||
@ -1449,7 +1474,7 @@ class ShinePyBotService:
|
|||||||
"/tasks — список ваших задач и предложений",
|
"/tasks — список ваших задач и предложений",
|
||||||
"/stop — остановить текущую задачу",
|
"/stop — остановить текущую задачу",
|
||||||
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
||||||
"/new — архивировать историю и начать новую",
|
"/new — архивировать историю и начать новую Codex-сессию",
|
||||||
"/help — эта справка",
|
"/help — эта справка",
|
||||||
]
|
]
|
||||||
if is_owner:
|
if is_owner:
|
||||||
@ -1680,9 +1705,31 @@ class ShinePyBotService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _run_codex(self, prompt: str, job: dict[str, Any]) -> str:
|
def _run_codex(self, prompt: str, job: dict[str, Any]) -> str:
|
||||||
|
username = job.get("username") or self.cfg.allowed_username
|
||||||
|
thread_id = self._codex_thread_id_for_user(username)
|
||||||
|
try:
|
||||||
|
return self._run_codex_once(prompt, job, thread_id=thread_id)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if not thread_id or not self._is_missing_codex_session_error(str(e)):
|
||||||
|
raise
|
||||||
|
self._set_codex_thread_id_for_user(username, "")
|
||||||
|
self._append_history(
|
||||||
|
Path(job["history_file"]),
|
||||||
|
"system_event",
|
||||||
|
{
|
||||||
|
"event": "codex_thread_reset",
|
||||||
|
"reason": "missing_session",
|
||||||
|
"username": normalize_username(username),
|
||||||
|
"oldThreadId": thread_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self._run_codex_once(prompt, job, thread_id="")
|
||||||
|
|
||||||
|
def _run_codex_once(self, prompt: str, job: dict[str, Any], *, thread_id: str) -> str:
|
||||||
output_lines: list[str] = []
|
output_lines: list[str] = []
|
||||||
job_id = str(job["id"])
|
job_id = str(job["id"])
|
||||||
job_num = job.get("num", "?")
|
job_num = job.get("num", "?")
|
||||||
|
username = job.get("username") or self.cfg.allowed_username
|
||||||
with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp:
|
||||||
output_file = Path(tmp.name)
|
output_file = Path(tmp.name)
|
||||||
|
|
||||||
@ -1693,9 +1740,12 @@ class ShinePyBotService:
|
|||||||
"--json",
|
"--json",
|
||||||
"-C", str(self.cfg.codex_workdir),
|
"-C", str(self.cfg.codex_workdir),
|
||||||
"-o", str(output_file),
|
"-o", str(output_file),
|
||||||
prompt,
|
|
||||||
]
|
]
|
||||||
print(f"[py-bot] codex exec start job={job_id[:8]}", flush=True)
|
if thread_id:
|
||||||
|
cmd.extend(["resume", thread_id])
|
||||||
|
cmd.append(prompt)
|
||||||
|
mode = f"resume {thread_id}" if thread_id else "new"
|
||||||
|
print(f"[py-bot] codex exec start job={job_id[:8]} mode={mode}", flush=True)
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
@ -1714,10 +1764,14 @@ class ShinePyBotService:
|
|||||||
last_user_note_at = 0.0
|
last_user_note_at = 0.0
|
||||||
codex_started_at = time.time()
|
codex_started_at = time.time()
|
||||||
last_job_message_at = codex_started_at
|
last_job_message_at = codex_started_at
|
||||||
|
seen_thread_id = ""
|
||||||
|
|
||||||
def on_line(line: str) -> None:
|
def on_line(line: str) -> None:
|
||||||
nonlocal last_user_note, last_user_note_at, last_job_message_at
|
nonlocal last_user_note, last_user_note_at, last_job_message_at, seen_thread_id
|
||||||
output_lines.append(line)
|
output_lines.append(line)
|
||||||
|
current_thread_id = self._extract_codex_thread_id(line)
|
||||||
|
if current_thread_id:
|
||||||
|
seen_thread_id = current_thread_id
|
||||||
note = self._extract_codex_user_note(line)
|
note = self._extract_codex_user_note(line)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if note and note != last_user_note and now - last_user_note_at > 8:
|
if note and note != last_user_note and now - last_user_note_at > 8:
|
||||||
@ -1770,6 +1824,9 @@ class ShinePyBotService:
|
|||||||
tail = "\n".join(output_lines[-40:])
|
tail = "\n".join(output_lines[-40:])
|
||||||
raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}")
|
raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}")
|
||||||
|
|
||||||
|
if seen_thread_id and seen_thread_id != thread_id:
|
||||||
|
self._set_codex_thread_id_for_user(username, seen_thread_id)
|
||||||
|
|
||||||
if output_file.exists():
|
if output_file.exists():
|
||||||
answer = output_file.read_text(encoding="utf-8").strip()
|
answer = output_file.read_text(encoding="utf-8").strip()
|
||||||
try:
|
try:
|
||||||
@ -2829,6 +2886,35 @@ class ShinePyBotService:
|
|||||||
return line
|
return line
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_codex_thread_id(line: str) -> str:
|
||||||
|
s = (line or "").strip()
|
||||||
|
if not s.startswith("{"):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
obj = json.loads(s)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if obj.get("type") != "thread.started":
|
||||||
|
return ""
|
||||||
|
thread_id = (obj.get("thread_id") or "").strip()
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_missing_codex_session_error(text: str) -> bool:
|
||||||
|
lowered = (text or "").lower()
|
||||||
|
markers = [
|
||||||
|
"session not found",
|
||||||
|
"conversation not found",
|
||||||
|
"thread not found",
|
||||||
|
"no session found",
|
||||||
|
"invalid session",
|
||||||
|
"unknown session",
|
||||||
|
"no conversation found",
|
||||||
|
"unknown thread",
|
||||||
|
]
|
||||||
|
return any(marker in lowered for marker in markers)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_duration(seconds: int) -> str:
|
def _format_duration(seconds: int) -> str:
|
||||||
seconds = max(0, seconds)
|
seconds = max(0, seconds)
|
||||||
|
|||||||
@ -190,6 +190,47 @@ public final class DatabaseInitializer {
|
|||||||
ON active_sessions (login);
|
ON active_sessions (login);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS esp_pairing_settings (
|
||||||
|
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
ttl_seconds INTEGER NOT NULL DEFAULT 300,
|
||||||
|
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_failed_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blocked_until_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS esp_pairing_requests (
|
||||||
|
pairing_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
requester_session_key TEXT NOT NULL,
|
||||||
|
requester_session_type INTEGER NOT NULL DEFAULT 1,
|
||||||
|
requester_client_platform TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_type INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
short_code TEXT NOT NULL,
|
||||||
|
fingerprint_b58 TEXT NOT NULL,
|
||||||
|
encrypted_payload TEXT,
|
||||||
|
reject_reason TEXT,
|
||||||
|
approved_by_session_id TEXT,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
expires_at_ms INTEGER NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
delivered_to_homeserver INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status
|
||||||
|
ON esp_pairing_requests (login, status, expires_at_ms);
|
||||||
|
""");
|
||||||
|
|
||||||
// 3. users_params
|
// 3. users_params
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS users_params (
|
CREATE TABLE IF NOT EXISTS users_params (
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
private static volatile SqliteDbController instance;
|
||||||
private static final int LATEST_SCHEMA_VERSION = 4;
|
private static final int LATEST_SCHEMA_VERSION = 5;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -87,6 +87,7 @@ public final class SqliteDbController {
|
|||||||
case 2 -> migrateToV2();
|
case 2 -> migrateToV2();
|
||||||
case 3 -> migrateToV3();
|
case 3 -> migrateToV3();
|
||||||
case 4 -> migrateToV4();
|
case 4 -> migrateToV4();
|
||||||
|
case 5 -> migrateToV5();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,6 +190,25 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateToV5() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
ensureEspPairingTables(st);
|
||||||
|
setSchemaVersion(c, 5);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v5 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v5 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||||
@ -266,6 +286,49 @@ public final class SqliteDbController {
|
|||||||
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''");
|
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureEspPairingTables(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS esp_pairing_settings (
|
||||||
|
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
ttl_seconds INTEGER NOT NULL DEFAULT 300,
|
||||||
|
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_failed_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blocked_until_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS esp_pairing_requests (
|
||||||
|
pairing_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
requester_session_key TEXT NOT NULL,
|
||||||
|
requester_session_type INTEGER NOT NULL DEFAULT 1,
|
||||||
|
requester_client_platform TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_type INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
short_code TEXT NOT NULL,
|
||||||
|
fingerprint_b58 TEXT NOT NULL,
|
||||||
|
encrypted_payload TEXT,
|
||||||
|
reject_reason TEXT,
|
||||||
|
approved_by_session_id TEXT,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
expires_at_ms INTEGER NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
delivered_to_homeserver INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status
|
||||||
|
ON esp_pairing_requests (login, status, expires_at_ms);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
||||||
try (Statement probe = c.createStatement();
|
try (Statement probe = c.createStatement();
|
||||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
||||||
|
|||||||
@ -0,0 +1,249 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class EspPairingRequestsDAO {
|
||||||
|
|
||||||
|
private static volatile EspPairingRequestsDAO instance;
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private EspPairingRequestsDAO() { }
|
||||||
|
|
||||||
|
public static EspPairingRequestsDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (EspPairingRequestsDAO.class) {
|
||||||
|
if (instance == null) instance = new EspPairingRequestsDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert(EspPairingRequestEntry entry) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
insert(c, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert(Connection c, EspPairingRequestEntry entry) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO esp_pairing_requests (
|
||||||
|
pairing_id,
|
||||||
|
login,
|
||||||
|
requester_session_key,
|
||||||
|
requester_session_type,
|
||||||
|
requester_client_platform,
|
||||||
|
payload_type,
|
||||||
|
status,
|
||||||
|
short_code,
|
||||||
|
fingerprint_b58,
|
||||||
|
encrypted_payload,
|
||||||
|
reject_reason,
|
||||||
|
approved_by_session_id,
|
||||||
|
created_at_ms,
|
||||||
|
expires_at_ms,
|
||||||
|
updated_at_ms,
|
||||||
|
delivered_to_homeserver
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getPairingId());
|
||||||
|
ps.setString(2, entry.getLogin());
|
||||||
|
ps.setString(3, entry.getRequesterSessionKey());
|
||||||
|
ps.setInt(4, entry.getRequesterSessionType());
|
||||||
|
ps.setString(5, entry.getRequesterClientPlatform());
|
||||||
|
ps.setInt(6, entry.getPayloadType());
|
||||||
|
ps.setString(7, entry.getStatus());
|
||||||
|
ps.setString(8, entry.getShortCode());
|
||||||
|
ps.setString(9, entry.getFingerprintB58());
|
||||||
|
ps.setString(10, entry.getEncryptedPayload());
|
||||||
|
ps.setString(11, entry.getRejectReason());
|
||||||
|
ps.setString(12, entry.getApprovedBySessionId());
|
||||||
|
ps.setLong(13, entry.getCreatedAtMs());
|
||||||
|
ps.setLong(14, entry.getExpiresAtMs());
|
||||||
|
ps.setLong(15, entry.getUpdatedAtMs());
|
||||||
|
ps.setInt(16, entry.isDeliveredToHomeserver() ? 1 : 0);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public EspPairingRequestEntry getByPairingId(String pairingId) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getByPairingId(c, pairingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public EspPairingRequestEntry getByPairingId(Connection c, String pairingId) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform,
|
||||||
|
payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason,
|
||||||
|
approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver
|
||||||
|
FROM esp_pairing_requests
|
||||||
|
WHERE pairing_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, pairingId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EspPairingRequestEntry> listActiveByLogin(String login, long nowMs) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listActiveByLogin(c, login, nowMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EspPairingRequestEntry> listActiveByLogin(Connection c, String login, long nowMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform,
|
||||||
|
payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason,
|
||||||
|
approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver
|
||||||
|
FROM esp_pairing_requests
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
AND expires_at_ms > ?
|
||||||
|
AND status IN ('created', 'approved', 'rejected')
|
||||||
|
ORDER BY created_at_ms DESC
|
||||||
|
""";
|
||||||
|
List<EspPairingRequestEntry> list = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setLong(2, nowMs);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) list.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countRecentByLoginAndStatuses(String login, long sinceMs, String... statuses) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM esp_pairing_requests
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
AND created_at_ms >= ?
|
||||||
|
AND status IN (
|
||||||
|
""");
|
||||||
|
for (int i = 0; i < statuses.length; i++) {
|
||||||
|
if (i > 0) sql.append(", ");
|
||||||
|
sql.append("?");
|
||||||
|
}
|
||||||
|
sql.append(")");
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql.toString())) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setLong(2, sinceMs);
|
||||||
|
for (int i = 0; i < statuses.length; i++) {
|
||||||
|
ps.setString(3 + i, statuses[i]);
|
||||||
|
}
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getInt(1) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateDeliveryFlag(String pairingId, boolean delivered, long updatedAtMs) throws SQLException {
|
||||||
|
updateSimple(pairingId, """
|
||||||
|
UPDATE esp_pairing_requests
|
||||||
|
SET delivered_to_homeserver = ?, updated_at_ms = ?
|
||||||
|
WHERE pairing_id = ?
|
||||||
|
""", ps -> {
|
||||||
|
ps.setInt(1, delivered ? 1 : 0);
|
||||||
|
ps.setLong(2, updatedAtMs);
|
||||||
|
ps.setString(3, pairingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markApproved(String pairingId, String encryptedPayload, String approvedBySessionId, long updatedAtMs) throws SQLException {
|
||||||
|
updateSimple(pairingId, """
|
||||||
|
UPDATE esp_pairing_requests
|
||||||
|
SET status = 'approved',
|
||||||
|
encrypted_payload = ?,
|
||||||
|
approved_by_session_id = ?,
|
||||||
|
reject_reason = NULL,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE pairing_id = ?
|
||||||
|
""", ps -> {
|
||||||
|
ps.setString(1, encryptedPayload);
|
||||||
|
ps.setString(2, approvedBySessionId);
|
||||||
|
ps.setLong(3, updatedAtMs);
|
||||||
|
ps.setString(4, pairingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markRejected(String pairingId, String rejectReason, String approvedBySessionId, long updatedAtMs) throws SQLException {
|
||||||
|
updateSimple(pairingId, """
|
||||||
|
UPDATE esp_pairing_requests
|
||||||
|
SET status = 'rejected',
|
||||||
|
reject_reason = ?,
|
||||||
|
approved_by_session_id = ?,
|
||||||
|
encrypted_payload = NULL,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE pairing_id = ?
|
||||||
|
""", ps -> {
|
||||||
|
ps.setString(1, rejectReason);
|
||||||
|
ps.setString(2, approvedBySessionId);
|
||||||
|
ps.setLong(3, updatedAtMs);
|
||||||
|
ps.setString(4, pairingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public int expirePending(long nowMs) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE esp_pairing_requests
|
||||||
|
SET status = 'expired',
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE status = 'created'
|
||||||
|
AND expires_at_ms <= ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, nowMs);
|
||||||
|
ps.setLong(2, nowMs);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface PreparedStatementSetter {
|
||||||
|
void accept(PreparedStatement ps) throws SQLException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSimple(String pairingId, String sql, PreparedStatementSetter setter) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
setter.accept(ps);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EspPairingRequestEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
EspPairingRequestEntry entry = new EspPairingRequestEntry();
|
||||||
|
entry.setPairingId(rs.getString("pairing_id"));
|
||||||
|
entry.setLogin(rs.getString("login"));
|
||||||
|
entry.setRequesterSessionKey(rs.getString("requester_session_key"));
|
||||||
|
entry.setRequesterSessionType(rs.getInt("requester_session_type"));
|
||||||
|
entry.setRequesterClientPlatform(rs.getString("requester_client_platform"));
|
||||||
|
entry.setPayloadType(rs.getInt("payload_type"));
|
||||||
|
entry.setStatus(rs.getString("status"));
|
||||||
|
entry.setShortCode(rs.getString("short_code"));
|
||||||
|
entry.setFingerprintB58(rs.getString("fingerprint_b58"));
|
||||||
|
entry.setEncryptedPayload(rs.getString("encrypted_payload"));
|
||||||
|
entry.setRejectReason(rs.getString("reject_reason"));
|
||||||
|
entry.setApprovedBySessionId(rs.getString("approved_by_session_id"));
|
||||||
|
entry.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
entry.setExpiresAtMs(rs.getLong("expires_at_ms"));
|
||||||
|
entry.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
entry.setDeliveredToHomeserver(rs.getInt("delivered_to_homeserver") != 0);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.entities.EspPairingSettingsEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public final class EspPairingSettingsDAO {
|
||||||
|
|
||||||
|
private static volatile EspPairingSettingsDAO instance;
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private EspPairingSettingsDAO() { }
|
||||||
|
|
||||||
|
public static EspPairingSettingsDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (EspPairingSettingsDAO.class) {
|
||||||
|
if (instance == null) instance = new EspPairingSettingsDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsert(EspPairingSettingsEntry entry) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
upsert(c, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsert(Connection c, EspPairingSettingsEntry entry) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO esp_pairing_settings (
|
||||||
|
login,
|
||||||
|
enabled,
|
||||||
|
password_hash,
|
||||||
|
ttl_seconds,
|
||||||
|
failed_attempts,
|
||||||
|
first_failed_at_ms,
|
||||||
|
blocked_until_ms,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(login) DO UPDATE SET
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
ttl_seconds = excluded.ttl_seconds,
|
||||||
|
failed_attempts = excluded.failed_attempts,
|
||||||
|
first_failed_at_ms = excluded.first_failed_at_ms,
|
||||||
|
blocked_until_ms = excluded.blocked_until_ms,
|
||||||
|
updated_at_ms = excluded.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getLogin());
|
||||||
|
ps.setInt(2, entry.isEnabled() ? 1 : 0);
|
||||||
|
ps.setString(3, entry.getPasswordHash());
|
||||||
|
ps.setInt(4, entry.getTtlSeconds());
|
||||||
|
ps.setInt(5, entry.getFailedAttempts());
|
||||||
|
ps.setLong(6, entry.getFirstFailedAtMs());
|
||||||
|
ps.setLong(7, entry.getBlockedUntilMs());
|
||||||
|
ps.setLong(8, entry.getUpdatedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public EspPairingSettingsEntry getByLogin(String login) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getByLogin(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public EspPairingSettingsEntry getByLogin(Connection c, String login) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms
|
||||||
|
FROM esp_pairing_settings
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EspPairingSettingsEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
EspPairingSettingsEntry entry = new EspPairingSettingsEntry();
|
||||||
|
entry.setLogin(rs.getString("login"));
|
||||||
|
entry.setEnabled(rs.getInt("enabled") != 0);
|
||||||
|
entry.setPasswordHash(rs.getString("password_hash"));
|
||||||
|
entry.setTtlSeconds(rs.getInt("ttl_seconds"));
|
||||||
|
entry.setFailedAttempts(rs.getInt("failed_attempts"));
|
||||||
|
entry.setFirstFailedAtMs(rs.getLong("first_failed_at_ms"));
|
||||||
|
entry.setBlockedUntilMs(rs.getLong("blocked_until_ms"));
|
||||||
|
entry.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class EspPairingRequestEntry {
|
||||||
|
|
||||||
|
private String pairingId;
|
||||||
|
private String login;
|
||||||
|
private String requesterSessionKey;
|
||||||
|
private int requesterSessionType;
|
||||||
|
private String requesterClientPlatform;
|
||||||
|
private int payloadType;
|
||||||
|
private String status;
|
||||||
|
private String shortCode;
|
||||||
|
private String fingerprintB58;
|
||||||
|
private String encryptedPayload;
|
||||||
|
private String rejectReason;
|
||||||
|
private String approvedBySessionId;
|
||||||
|
private long createdAtMs;
|
||||||
|
private long expiresAtMs;
|
||||||
|
private long updatedAtMs;
|
||||||
|
private boolean deliveredToHomeserver;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogin() {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogin(String login) {
|
||||||
|
this.login = login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterSessionKey() {
|
||||||
|
return requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionKey(String requesterSessionKey) {
|
||||||
|
this.requesterSessionKey = requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRequesterSessionType() {
|
||||||
|
return requesterSessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionType(int requesterSessionType) {
|
||||||
|
this.requesterSessionType = requesterSessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterClientPlatform() {
|
||||||
|
return requesterClientPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterClientPlatform(String requesterClientPlatform) {
|
||||||
|
this.requesterClientPlatform = requesterClientPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPayloadType() {
|
||||||
|
return payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayloadType(int payloadType) {
|
||||||
|
this.payloadType = payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getShortCode() {
|
||||||
|
return shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShortCode(String shortCode) {
|
||||||
|
this.shortCode = shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFingerprintB58() {
|
||||||
|
return fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprintB58(String fingerprintB58) {
|
||||||
|
this.fingerprintB58 = fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEncryptedPayload() {
|
||||||
|
return encryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncryptedPayload(String encryptedPayload) {
|
||||||
|
this.encryptedPayload = encryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRejectReason() {
|
||||||
|
return rejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRejectReason(String rejectReason) {
|
||||||
|
this.rejectReason = rejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApprovedBySessionId() {
|
||||||
|
return approvedBySessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApprovedBySessionId(String approvedBySessionId) {
|
||||||
|
this.approvedBySessionId = approvedBySessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCreatedAtMs() {
|
||||||
|
return createdAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAtMs(long createdAtMs) {
|
||||||
|
this.createdAtMs = createdAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpiresAtMs() {
|
||||||
|
return expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAtMs(long expiresAtMs) {
|
||||||
|
this.expiresAtMs = expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUpdatedAtMs() {
|
||||||
|
return updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) {
|
||||||
|
this.updatedAtMs = updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDeliveredToHomeserver() {
|
||||||
|
return deliveredToHomeserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredToHomeserver(boolean deliveredToHomeserver) {
|
||||||
|
this.deliveredToHomeserver = deliveredToHomeserver;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class EspPairingSettingsEntry {
|
||||||
|
|
||||||
|
private String login;
|
||||||
|
private boolean enabled;
|
||||||
|
private String passwordHash;
|
||||||
|
private int ttlSeconds;
|
||||||
|
private int failedAttempts;
|
||||||
|
private long firstFailedAtMs;
|
||||||
|
private long blockedUntilMs;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getLogin() {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogin(String login) {
|
||||||
|
this.login = login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTtlSeconds() {
|
||||||
|
return ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTtlSeconds(int ttlSeconds) {
|
||||||
|
this.ttlSeconds = ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getFailedAttempts() {
|
||||||
|
return failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedAttempts(int failedAttempts) {
|
||||||
|
this.failedAttempts = failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFirstFailedAtMs() {
|
||||||
|
return firstFailedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFirstFailedAtMs(long firstFailedAtMs) {
|
||||||
|
this.firstFailedAtMs = firstFailedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getBlockedUntilMs() {
|
||||||
|
return blockedUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBlockedUntilMs(long blockedUntilMs) {
|
||||||
|
this.blockedUntilMs = blockedUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUpdatedAtMs() {
|
||||||
|
return updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) {
|
||||||
|
this.updatedAtMs = updatedAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,20 +7,32 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
|
||||||
|
|
||||||
// --- NEW v2 session login ---
|
// --- NEW v2 session login ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_StartEspPairing_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_Handler;
|
||||||
|
|
||||||
// --- auth entities ---
|
// --- auth entities ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request;
|
||||||
|
|
||||||
// --- NEW v2 entities ---
|
// --- NEW v2 entities ---
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
@ -121,6 +133,12 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- login to existing session in 2 steps ---
|
// --- login to existing session in 2 steps ---
|
||||||
Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()),
|
Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()),
|
||||||
Map.entry("SessionLogin", new Net_SessionLogin_Handler()),
|
Map.entry("SessionLogin", new Net_SessionLogin_Handler()),
|
||||||
|
Map.entry("UpsertEspPairingSettings", new Net_UpsertEspPairingSettings_Handler()),
|
||||||
|
Map.entry("StartEspPairing", new Net_StartEspPairing_Handler()),
|
||||||
|
Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()),
|
||||||
|
Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()),
|
||||||
|
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
|
||||||
|
Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()),
|
||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
@ -179,6 +197,12 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- NEW v2 ---
|
// --- NEW v2 ---
|
||||||
Map.entry("SessionChallenge", Net_SessionChallenge_Request.class),
|
Map.entry("SessionChallenge", Net_SessionChallenge_Request.class),
|
||||||
Map.entry("SessionLogin", Net_SessionLogin_Request.class),
|
Map.entry("SessionLogin", Net_SessionLogin_Request.class),
|
||||||
|
Map.entry("UpsertEspPairingSettings", Net_UpsertEspPairingSettings_Request.class),
|
||||||
|
Map.entry("StartEspPairing", Net_StartEspPairing_Request.class),
|
||||||
|
Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class),
|
||||||
|
Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class),
|
||||||
|
Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class),
|
||||||
|
Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class),
|
||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import org.eclipse.jetty.websocket.api.Session;
|
||||||
|
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
final class EspPairingSupport {
|
||||||
|
|
||||||
|
static final int DEFAULT_TTL_SECONDS = 300;
|
||||||
|
static final int MIN_TTL_SECONDS = 60;
|
||||||
|
static final int MAX_TTL_SECONDS = 1800;
|
||||||
|
static final int REQUEST_RATE_LIMIT = 5;
|
||||||
|
static final long REQUEST_RATE_WINDOW_MS = 5 * 60_000L;
|
||||||
|
|
||||||
|
static final int STATUS_PAIRING_REQUIRES_AUTH_SESSION = 463;
|
||||||
|
static final int STATUS_PAIRING_RATE_LIMIT = 429;
|
||||||
|
|
||||||
|
static final String STATE_CREATED = "created";
|
||||||
|
static final String STATE_APPROVED = "approved";
|
||||||
|
static final String STATE_REJECTED = "rejected";
|
||||||
|
static final String STATE_EXPIRED = "expired";
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
|
||||||
|
|
||||||
|
private EspPairingSupport() {}
|
||||||
|
|
||||||
|
static boolean isTrustedUserSession(ConnectionContext ctx) {
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser()) return false;
|
||||||
|
ActiveSessionEntry activeSession = ctx.getActiveSession();
|
||||||
|
return activeSession != null && activeSession.getSessionId() != null && !activeSession.getSessionId().isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ConnectionContext> findOnlineTrustedConnections(String login) {
|
||||||
|
List<ConnectionContext> out = new ArrayList<>();
|
||||||
|
for (ConnectionContext candidate : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
|
||||||
|
if (!isTrustedUserSession(candidate)) continue;
|
||||||
|
Session ws = candidate.getWsSession();
|
||||||
|
if (ws == null || !ws.isOpen()) continue;
|
||||||
|
out.add(candidate);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int normalizeTtlSeconds(Integer raw) {
|
||||||
|
if (raw == null) return DEFAULT_TTL_SECONDS;
|
||||||
|
int value = raw;
|
||||||
|
if (value < MIN_TTL_SECONDS) return MIN_TTL_SECONDS;
|
||||||
|
if (value > MAX_TTL_SECONDS) return MAX_TTL_SECONDS;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int normalizePayloadType(Integer raw) {
|
||||||
|
if (raw == null) return 1;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isSupportedPayloadType(int payloadType) {
|
||||||
|
return payloadType >= 1 && payloadType <= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeOpaqueHash(String raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
String value = raw.trim();
|
||||||
|
if (value.isEmpty()) return null;
|
||||||
|
if (value.length() > 512) return value.substring(0, 512);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeEncryptedPayload(String raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
String value = raw.trim();
|
||||||
|
if (value.isEmpty()) return null;
|
||||||
|
if (value.length() > 32768) return value.substring(0, 32768);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeReason(String raw) {
|
||||||
|
if (raw == null) return "";
|
||||||
|
String value = raw.trim();
|
||||||
|
if (value.length() <= 160) return value;
|
||||||
|
return value.substring(0, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
static PairingFingerprint deriveFingerprint(String login,
|
||||||
|
String requesterSessionKey,
|
||||||
|
int requesterSessionType,
|
||||||
|
String clientPlatform,
|
||||||
|
int payloadType,
|
||||||
|
long createdAtMs) throws Exception {
|
||||||
|
String canonical = (login == null ? "" : login.toLowerCase(Locale.ROOT).trim())
|
||||||
|
+ "|" + requesterSessionKey
|
||||||
|
+ "|" + requesterSessionType
|
||||||
|
+ "|" + (clientPlatform == null ? "" : clientPlatform.trim())
|
||||||
|
+ "|" + payloadType
|
||||||
|
+ "|" + createdAtMs;
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8));
|
||||||
|
long code = ((digest[0] & 0xFFL) << 24)
|
||||||
|
| ((digest[1] & 0xFFL) << 16)
|
||||||
|
| ((digest[2] & 0xFFL) << 8)
|
||||||
|
| (digest[3] & 0xFFL);
|
||||||
|
long shortCodeNum = code % 10_000_000L;
|
||||||
|
String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum);
|
||||||
|
return new PairingFingerprint(shortCode, toBase58(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String newPairingId() {
|
||||||
|
byte[] random = new byte[32];
|
||||||
|
RANDOM.nextBytes(random);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String toBase58(byte[] input) {
|
||||||
|
if (input == null || input.length == 0) return "";
|
||||||
|
int zeros = 0;
|
||||||
|
while (zeros < input.length && input[zeros] == 0) zeros++;
|
||||||
|
byte[] copy = input.clone();
|
||||||
|
byte[] tmp = new byte[copy.length * 2];
|
||||||
|
int j = tmp.length;
|
||||||
|
int startAt = zeros;
|
||||||
|
while (startAt < copy.length) {
|
||||||
|
int mod = divmod58(copy, startAt);
|
||||||
|
if (copy[startAt] == 0) startAt++;
|
||||||
|
tmp[--j] = (byte) BASE58[mod];
|
||||||
|
}
|
||||||
|
while (j < tmp.length && tmp[j] == BASE58[0]) j++;
|
||||||
|
while (--zeros >= 0) tmp[--j] = (byte) BASE58[0];
|
||||||
|
return new String(tmp, j, tmp.length - j, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int divmod58(byte[] number, int startAt) {
|
||||||
|
int remainder = 0;
|
||||||
|
for (int i = startAt; i < number.length; i++) {
|
||||||
|
int digit256 = number[i] & 0xFF;
|
||||||
|
int temp = remainder * 256 + digit256;
|
||||||
|
number[i] = (byte) (temp / 58);
|
||||||
|
remainder = temp % 58;
|
||||||
|
}
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
record PairingFingerprint(String shortCode, String fingerprintB58) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.EspPairingRequestsDAO;
|
||||||
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
|
||||||
|
public class Net_ApproveEspPairing_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_ApproveEspPairing_Request req = (Net_ApproveEspPairing_Request) baseReq;
|
||||||
|
if (!EspPairingSupport.isTrustedUserSession(ctx)) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION,
|
||||||
|
"PAIRING_REQUIRES_AUTH_SESSION",
|
||||||
|
"Операция доступна только для авторизованной доверенной сессии пользователя"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim();
|
||||||
|
if (pairingId.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId");
|
||||||
|
}
|
||||||
|
String encryptedPayload = EspPairingSupport.normalizeEncryptedPayload(req.getEncryptedPayload());
|
||||||
|
if (encryptedPayload == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_ENCRYPTED_PAYLOAD", "Пустой encryptedPayload");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
EspPairingRequestsDAO.getInstance().expirePending(now);
|
||||||
|
EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId);
|
||||||
|
if (row == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена");
|
||||||
|
}
|
||||||
|
if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя подтверждать pairing другого пользователя");
|
||||||
|
}
|
||||||
|
if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created");
|
||||||
|
}
|
||||||
|
if (row.getExpiresAtMs() <= now) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_EXPIRED", "Срок действия pairing-заявки истёк");
|
||||||
|
}
|
||||||
|
|
||||||
|
EspPairingRequestsDAO.getInstance().markApproved(pairingId, encryptedPayload, ctx.getSessionId(), now);
|
||||||
|
|
||||||
|
Net_ApproveEspPairing_Response resp = new Net_ApproveEspPairing_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setPairingId(pairingId);
|
||||||
|
resp.setState(EspPairingSupport.STATE_APPROVED);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.EspPairingRequestsDAO;
|
||||||
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
|
||||||
|
public class Net_GetEspPairingStatus_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_GetEspPairingStatus_Request req = (Net_GetEspPairingStatus_Request) baseReq;
|
||||||
|
String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim();
|
||||||
|
if (pairingId.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
EspPairingRequestsDAO.getInstance().expirePending(now);
|
||||||
|
EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId);
|
||||||
|
if (row == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
String state = row.getStatus();
|
||||||
|
if (row.getExpiresAtMs() <= now && EspPairingSupport.STATE_CREATED.equals(state)) {
|
||||||
|
state = EspPairingSupport.STATE_EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetEspPairingStatus_Response resp = new Net_GetEspPairingStatus_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setPairingId(row.getPairingId());
|
||||||
|
resp.setState(state);
|
||||||
|
resp.setShortCode(row.getShortCode());
|
||||||
|
resp.setFingerprintB58(row.getFingerprintB58());
|
||||||
|
resp.setPayloadType(row.getPayloadType());
|
||||||
|
resp.setEncryptedPayload(row.getEncryptedPayload());
|
||||||
|
resp.setRejectReason(row.getRejectReason());
|
||||||
|
resp.setExpiresAtMs(row.getExpiresAtMs());
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.EspPairingRequestsDAO;
|
||||||
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_ListEspPairingRequests_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_ListEspPairingRequests_Request req = (Net_ListEspPairingRequests_Request) baseReq;
|
||||||
|
if (!EspPairingSupport.isTrustedUserSession(ctx)) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION,
|
||||||
|
"PAIRING_REQUIRES_AUTH_SESSION",
|
||||||
|
"Операция доступна только для авторизованной доверенной сессии пользователя"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
EspPairingRequestsDAO.getInstance().expirePending(now);
|
||||||
|
List<EspPairingRequestEntry> rows = EspPairingRequestsDAO.getInstance().listActiveByLogin(ctx.getLogin(), now);
|
||||||
|
List<Net_ListEspPairingRequests_Response.PairingRequestItem> items = new ArrayList<>();
|
||||||
|
for (EspPairingRequestEntry row : rows) {
|
||||||
|
Net_ListEspPairingRequests_Response.PairingRequestItem item = new Net_ListEspPairingRequests_Response.PairingRequestItem();
|
||||||
|
item.setPairingId(row.getPairingId());
|
||||||
|
item.setState(row.getStatus());
|
||||||
|
item.setRequesterSessionKey(row.getRequesterSessionKey());
|
||||||
|
item.setRequesterSessionType(row.getRequesterSessionType());
|
||||||
|
item.setRequesterClientPlatform(row.getRequesterClientPlatform());
|
||||||
|
item.setPayloadType(row.getPayloadType());
|
||||||
|
item.setShortCode(row.getShortCode());
|
||||||
|
item.setFingerprintB58(row.getFingerprintB58());
|
||||||
|
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||||
|
item.setExpiresAtMs(row.getExpiresAtMs());
|
||||||
|
item.setDeliveredToHomeserver(row.isDeliveredToHomeserver());
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_ListEspPairingRequests_Response resp = new Net_ListEspPairingRequests_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setRequests(items);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.EspPairingRequestsDAO;
|
||||||
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
|
||||||
|
public class Net_RejectEspPairing_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_RejectEspPairing_Request req = (Net_RejectEspPairing_Request) baseReq;
|
||||||
|
if (!EspPairingSupport.isTrustedUserSession(ctx)) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION,
|
||||||
|
"PAIRING_REQUIRES_AUTH_SESSION",
|
||||||
|
"Операция доступна только для авторизованной доверенной сессии пользователя"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim();
|
||||||
|
if (pairingId.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId");
|
||||||
|
}
|
||||||
|
String reason = EspPairingSupport.normalizeReason(req.getReason());
|
||||||
|
if (reason.isBlank()) reason = "rejected_by_homeserver";
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
EspPairingRequestsDAO.getInstance().expirePending(now);
|
||||||
|
EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId);
|
||||||
|
if (row == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена");
|
||||||
|
}
|
||||||
|
if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя отклонять pairing другого пользователя");
|
||||||
|
}
|
||||||
|
if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created");
|
||||||
|
}
|
||||||
|
|
||||||
|
EspPairingRequestsDAO.getInstance().markRejected(pairingId, reason, ctx.getSessionId(), now);
|
||||||
|
|
||||||
|
Net_RejectEspPairing_Response resp = new Net_RejectEspPairing_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setPairingId(pairingId);
|
||||||
|
resp.setState(EspPairingSupport.STATE_REJECTED);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.EspPairingRequestsDAO;
|
||||||
|
import shine.db.dao.EspPairingSettingsDAO;
|
||||||
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
import shine.db.entities.EspPairingSettingsEntry;
|
||||||
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_StartEspPairing_Request req = (Net_StartEspPairing_Request) baseReq;
|
||||||
|
|
||||||
|
String login = req.getLogin() == null ? "" : req.getLogin().trim();
|
||||||
|
if (login.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login");
|
||||||
|
}
|
||||||
|
|
||||||
|
String requesterSessionKey = req.getRequesterSessionKey();
|
||||||
|
if (requesterSessionKey == null || requesterSessionKey.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey");
|
||||||
|
AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey");
|
||||||
|
}
|
||||||
|
|
||||||
|
int requesterSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getRequesterSessionType());
|
||||||
|
if (!AuthSessionTypeSupport.isSupportedSessionType(requesterSessionType)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_TYPE", "Неподдерживаемый requesterSessionType");
|
||||||
|
}
|
||||||
|
int payloadType = EspPairingSupport.normalizePayloadType(req.getPayloadType());
|
||||||
|
if (!EspPairingSupport.isSupportedPayloadType(payloadType)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3");
|
||||||
|
}
|
||||||
|
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
||||||
|
if (passwordHash == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash");
|
||||||
|
}
|
||||||
|
|
||||||
|
SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login);
|
||||||
|
if (user == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
|
||||||
|
}
|
||||||
|
String canonicalLogin = user.getLogin();
|
||||||
|
|
||||||
|
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
|
||||||
|
if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
EspPairingRequestsDAO.getInstance().expirePending(now);
|
||||||
|
if (settings.getBlockedUntilMs() > now) {
|
||||||
|
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток");
|
||||||
|
}
|
||||||
|
int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses(
|
||||||
|
canonicalLogin,
|
||||||
|
now - EspPairingSupport.REQUEST_RATE_WINDOW_MS,
|
||||||
|
EspPairingSupport.STATE_CREATED,
|
||||||
|
EspPairingSupport.STATE_REJECTED
|
||||||
|
);
|
||||||
|
if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) {
|
||||||
|
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время");
|
||||||
|
}
|
||||||
|
if (!settings.getPasswordHash().equals(passwordHash)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль");
|
||||||
|
}
|
||||||
|
|
||||||
|
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
||||||
|
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds());
|
||||||
|
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||||
|
canonicalLogin,
|
||||||
|
requesterSessionKey,
|
||||||
|
requesterSessionType,
|
||||||
|
clientPlatform,
|
||||||
|
payloadType,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
EspPairingRequestEntry entry = new EspPairingRequestEntry();
|
||||||
|
entry.setPairingId(EspPairingSupport.newPairingId());
|
||||||
|
entry.setLogin(canonicalLogin);
|
||||||
|
entry.setRequesterSessionKey(requesterSessionKey);
|
||||||
|
entry.setRequesterSessionType(requesterSessionType);
|
||||||
|
entry.setRequesterClientPlatform(clientPlatform);
|
||||||
|
entry.setPayloadType(payloadType);
|
||||||
|
entry.setStatus(EspPairingSupport.STATE_CREATED);
|
||||||
|
entry.setShortCode(fingerprint.shortCode());
|
||||||
|
entry.setFingerprintB58(fingerprint.fingerprintB58());
|
||||||
|
entry.setCreatedAtMs(now);
|
||||||
|
entry.setExpiresAtMs(now + ttlSeconds * 1000L);
|
||||||
|
entry.setUpdatedAtMs(now);
|
||||||
|
entry.setDeliveredToHomeserver(false);
|
||||||
|
EspPairingRequestsDAO.getInstance().insert(entry);
|
||||||
|
|
||||||
|
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||||
|
boolean delivered = false;
|
||||||
|
for (ConnectionContext targetCtx : approverConnections) {
|
||||||
|
String eventId = NetIdGenerator.eventId("pair");
|
||||||
|
ObjectNode payload = MAPPER.createObjectNode();
|
||||||
|
payload.put("pairingId", entry.getPairingId());
|
||||||
|
payload.put("login", canonicalLogin);
|
||||||
|
payload.put("requesterSessionKey", requesterSessionKey);
|
||||||
|
payload.put("requesterSessionType", requesterSessionType);
|
||||||
|
payload.put("requesterClientPlatform", clientPlatform);
|
||||||
|
payload.put("payloadType", payloadType);
|
||||||
|
payload.put("shortCode", entry.getShortCode());
|
||||||
|
payload.put("fingerprintB58", entry.getFingerprintB58());
|
||||||
|
payload.put("createdAtMs", entry.getCreatedAtMs());
|
||||||
|
payload.put("expiresAtMs", entry.getExpiresAtMs());
|
||||||
|
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingEspPairingRequest", eventId, payload);
|
||||||
|
}
|
||||||
|
if (delivered) {
|
||||||
|
EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_StartEspPairing_Response resp = new Net_StartEspPairing_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setPairingId(entry.getPairingId());
|
||||||
|
resp.setState(entry.getStatus());
|
||||||
|
resp.setShortCode(entry.getShortCode());
|
||||||
|
resp.setFingerprintB58(entry.getFingerprintB58());
|
||||||
|
resp.setExpiresAtMs(entry.getExpiresAtMs());
|
||||||
|
resp.setTrustedSessionOnline(!approverConnections.isEmpty());
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.EspPairingSettingsDAO;
|
||||||
|
import shine.db.entities.EspPairingSettingsEntry;
|
||||||
|
|
||||||
|
public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_UpsertEspPairingSettings_Request req = (Net_UpsertEspPairingSettings_Request) baseReq;
|
||||||
|
|
||||||
|
if (!EspPairingSupport.isTrustedUserSession(ctx)) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION,
|
||||||
|
"PAIRING_REQUIRES_AUTH_SESSION",
|
||||||
|
"Операция доступна только для авторизованной доверенной сессии пользователя"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean enabled = req.getEnabled() != null && req.getEnabled();
|
||||||
|
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
||||||
|
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds());
|
||||||
|
if (enabled && (passwordHash == null || passwordHash.isBlank())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
EspPairingSettingsEntry entry = new EspPairingSettingsEntry();
|
||||||
|
entry.setLogin(ctx.getLogin());
|
||||||
|
entry.setEnabled(enabled);
|
||||||
|
entry.setPasswordHash(passwordHash == null ? "" : passwordHash);
|
||||||
|
entry.setTtlSeconds(ttlSeconds);
|
||||||
|
entry.setFailedAttempts(0);
|
||||||
|
entry.setFirstFailedAtMs(0L);
|
||||||
|
entry.setBlockedUntilMs(0L);
|
||||||
|
entry.setUpdatedAtMs(now);
|
||||||
|
EspPairingSettingsDAO.getInstance().upsert(entry);
|
||||||
|
|
||||||
|
Net_UpsertEspPairingSettings_Response resp = new Net_UpsertEspPairingSettings_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setEnabled(enabled);
|
||||||
|
resp.setTtlSeconds(ttlSeconds);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_ApproveEspPairing_Request extends Net_Request {
|
||||||
|
private String pairingId;
|
||||||
|
private String encryptedPayload;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEncryptedPayload() {
|
||||||
|
return encryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncryptedPayload(String encryptedPayload) {
|
||||||
|
this.encryptedPayload = encryptedPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_ApproveEspPairing_Response extends Net_Response {
|
||||||
|
private String pairingId;
|
||||||
|
private String state;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_GetEspPairingStatus_Request extends Net_Request {
|
||||||
|
private String pairingId;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_GetEspPairingStatus_Response extends Net_Response {
|
||||||
|
private String pairingId;
|
||||||
|
private String state;
|
||||||
|
private String shortCode;
|
||||||
|
private String fingerprintB58;
|
||||||
|
private int payloadType;
|
||||||
|
private String encryptedPayload;
|
||||||
|
private String rejectReason;
|
||||||
|
private long expiresAtMs;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getShortCode() {
|
||||||
|
return shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShortCode(String shortCode) {
|
||||||
|
this.shortCode = shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFingerprintB58() {
|
||||||
|
return fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprintB58(String fingerprintB58) {
|
||||||
|
this.fingerprintB58 = fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPayloadType() {
|
||||||
|
return payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayloadType(int payloadType) {
|
||||||
|
this.payloadType = payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEncryptedPayload() {
|
||||||
|
return encryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncryptedPayload(String encryptedPayload) {
|
||||||
|
this.encryptedPayload = encryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRejectReason() {
|
||||||
|
return rejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRejectReason(String rejectReason) {
|
||||||
|
this.rejectReason = rejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpiresAtMs() {
|
||||||
|
return expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAtMs(long expiresAtMs) {
|
||||||
|
this.expiresAtMs = expiresAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_ListEspPairingRequests_Request extends Net_Request {
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_ListEspPairingRequests_Response extends Net_Response {
|
||||||
|
private List<PairingRequestItem> requests = new ArrayList<>();
|
||||||
|
|
||||||
|
public List<PairingRequestItem> getRequests() {
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequests(List<PairingRequestItem> requests) {
|
||||||
|
this.requests = requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PairingRequestItem {
|
||||||
|
private String pairingId;
|
||||||
|
private String state;
|
||||||
|
private String requesterSessionKey;
|
||||||
|
private int requesterSessionType;
|
||||||
|
private String requesterClientPlatform;
|
||||||
|
private int payloadType;
|
||||||
|
private String shortCode;
|
||||||
|
private String fingerprintB58;
|
||||||
|
private long createdAtMs;
|
||||||
|
private long expiresAtMs;
|
||||||
|
private boolean deliveredToHomeserver;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterSessionKey() {
|
||||||
|
return requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionKey(String requesterSessionKey) {
|
||||||
|
this.requesterSessionKey = requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRequesterSessionType() {
|
||||||
|
return requesterSessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionType(int requesterSessionType) {
|
||||||
|
this.requesterSessionType = requesterSessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterClientPlatform() {
|
||||||
|
return requesterClientPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterClientPlatform(String requesterClientPlatform) {
|
||||||
|
this.requesterClientPlatform = requesterClientPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPayloadType() {
|
||||||
|
return payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayloadType(int payloadType) {
|
||||||
|
this.payloadType = payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getShortCode() {
|
||||||
|
return shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShortCode(String shortCode) {
|
||||||
|
this.shortCode = shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFingerprintB58() {
|
||||||
|
return fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprintB58(String fingerprintB58) {
|
||||||
|
this.fingerprintB58 = fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCreatedAtMs() {
|
||||||
|
return createdAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAtMs(long createdAtMs) {
|
||||||
|
this.createdAtMs = createdAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpiresAtMs() {
|
||||||
|
return expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAtMs(long expiresAtMs) {
|
||||||
|
this.expiresAtMs = expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDeliveredToHomeserver() {
|
||||||
|
return deliveredToHomeserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredToHomeserver(boolean deliveredToHomeserver) {
|
||||||
|
this.deliveredToHomeserver = deliveredToHomeserver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_RejectEspPairing_Request extends Net_Request {
|
||||||
|
private String pairingId;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_RejectEspPairing_Response extends Net_Response {
|
||||||
|
private String pairingId;
|
||||||
|
private String state;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_StartEspPairing_Request extends Net_Request {
|
||||||
|
private String login;
|
||||||
|
private String passwordHash;
|
||||||
|
private String requesterSessionKey;
|
||||||
|
private Integer requesterSessionType;
|
||||||
|
private String requesterClientPlatform;
|
||||||
|
private Integer payloadType;
|
||||||
|
|
||||||
|
public String getLogin() {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogin(String login) {
|
||||||
|
this.login = login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterSessionKey() {
|
||||||
|
return requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionKey(String requesterSessionKey) {
|
||||||
|
this.requesterSessionKey = requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRequesterSessionType() {
|
||||||
|
return requesterSessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionType(Integer requesterSessionType) {
|
||||||
|
this.requesterSessionType = requesterSessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterClientPlatform() {
|
||||||
|
return requesterClientPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterClientPlatform(String requesterClientPlatform) {
|
||||||
|
this.requesterClientPlatform = requesterClientPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPayloadType() {
|
||||||
|
return payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayloadType(Integer payloadType) {
|
||||||
|
this.payloadType = payloadType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_StartEspPairing_Response extends Net_Response {
|
||||||
|
private String pairingId;
|
||||||
|
private String state;
|
||||||
|
private String shortCode;
|
||||||
|
private String fingerprintB58;
|
||||||
|
private long expiresAtMs;
|
||||||
|
private boolean trustedSessionOnline;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getShortCode() {
|
||||||
|
return shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShortCode(String shortCode) {
|
||||||
|
this.shortCode = shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFingerprintB58() {
|
||||||
|
return fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprintB58(String fingerprintB58) {
|
||||||
|
this.fingerprintB58 = fingerprintB58;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpiresAtMs() {
|
||||||
|
return expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAtMs(long expiresAtMs) {
|
||||||
|
this.expiresAtMs = expiresAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTrustedSessionOnline() {
|
||||||
|
return trustedSessionOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedSessionOnline(boolean trustedSessionOnline) {
|
||||||
|
this.trustedSessionOnline = trustedSessionOnline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_UpsertEspPairingSettings_Request extends Net_Request {
|
||||||
|
private Boolean enabled;
|
||||||
|
private String passwordHash;
|
||||||
|
private Integer ttlSeconds;
|
||||||
|
|
||||||
|
public Boolean getEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(Boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTtlSeconds() {
|
||||||
|
return ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTtlSeconds(Integer ttlSeconds) {
|
||||||
|
this.ttlSeconds = ttlSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_UpsertEspPairingSettings_Response extends Net_Response {
|
||||||
|
private boolean enabled;
|
||||||
|
private int ttlSeconds;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTtlSeconds() {
|
||||||
|
return ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTtlSeconds(int ttlSeconds) {
|
||||||
|
this.ttlSeconds = ttlSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
170
SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java
Normal file
170
SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package test.it.cases;
|
||||||
|
|
||||||
|
import test.it.utils.TestConfig;
|
||||||
|
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 shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
public class IT_07_EspPairing {
|
||||||
|
|
||||||
|
private static final String LOGIN = TestConfig.LOGIN();
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
TestLog.info("Standalone: при необходимости локально создаю тестового пользователя напрямую в БД");
|
||||||
|
String summary = run();
|
||||||
|
System.out.println(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String run() {
|
||||||
|
TestResult r = new TestResult("IT_07_EspPairing");
|
||||||
|
Duration t = Duration.ofSeconds(5);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureUserSeeded();
|
||||||
|
Session clientSession = createSession(LOGIN, 1, "Web", t);
|
||||||
|
|
||||||
|
try (WsSession requesterWs = WsSession.open();
|
||||||
|
WsSession clientWs = WsSession.open()) {
|
||||||
|
|
||||||
|
sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r);
|
||||||
|
|
||||||
|
String passwordHash = "argon2id$v=19$m=65536,t=2,p=1$test$esp_pairing_hash";
|
||||||
|
String upsertResp = clientWs.call(
|
||||||
|
"UpsertEspPairingSettings",
|
||||||
|
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(upsertResp), "UpsertEspPairingSettings must be 200");
|
||||||
|
|
||||||
|
SessionMaterial requesterMaterial = newSessionMaterial();
|
||||||
|
String startResp = requesterWs.call(
|
||||||
|
"StartEspPairing",
|
||||||
|
JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterMaterial.sessionKey(), 1, "Android", 1),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(startResp), "StartEspPairing must be 200");
|
||||||
|
String pairingId = JsonParsers.payloadText(startResp, "pairingId");
|
||||||
|
assertNotNull(pairingId, "pairingId must be present");
|
||||||
|
assertEquals("created", JsonParsers.payloadText(startResp, "state"));
|
||||||
|
assertEquals(Boolean.TRUE, JsonParsers.payloadBoolean(startResp, "trustedSessionOnline"));
|
||||||
|
|
||||||
|
String listResp = clientWs.call("ListEspPairingRequests", JsonBuilders.listEspPairingRequests(), t);
|
||||||
|
assertEquals(200, JsonParsers.status(listResp), "ListEspPairingRequests must be 200");
|
||||||
|
assertTrue(listResp.contains(pairingId), "ListEspPairingRequests must contain created pairingId");
|
||||||
|
|
||||||
|
String approveResp = clientWs.call(
|
||||||
|
"ApproveEspPairing",
|
||||||
|
JsonBuilders.approveEspPairing(pairingId, "AQIDBA=="),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(approveResp), "ApproveEspPairing must be 200");
|
||||||
|
assertEquals("approved", JsonParsers.payloadText(approveResp, "state"));
|
||||||
|
|
||||||
|
String statusResp = requesterWs.call(
|
||||||
|
"GetEspPairingStatus",
|
||||||
|
JsonBuilders.getEspPairingStatus(pairingId),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(statusResp), "GetEspPairingStatus must be 200");
|
||||||
|
assertEquals("approved", JsonParsers.payloadText(statusResp, "state"));
|
||||||
|
assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload"));
|
||||||
|
|
||||||
|
String forbiddenResp = requesterWs.call(
|
||||||
|
"ListEspPairingRequests#anonymous",
|
||||||
|
JsonBuilders.listEspPairingRequests(),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION");
|
||||||
|
|
||||||
|
r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload");
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
r.fail("IT_07_EspPairing упал: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.summaryLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Session createSession(String login, int sessionType, String clientPlatform, Duration t) {
|
||||||
|
try (WsSession ws = WsSession.open()) {
|
||||||
|
String nonceResp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t);
|
||||||
|
assertEquals(200, JsonParsers.status(nonceResp));
|
||||||
|
String authNonce = JsonParsers.authNonce(nonceResp);
|
||||||
|
assertNotNull(authNonce);
|
||||||
|
|
||||||
|
SessionMaterial material = newSessionMaterial();
|
||||||
|
String storagePwd = TestConfig.fakeStoragePwd();
|
||||||
|
String createResp = ws.call(
|
||||||
|
"CreateAuthSession",
|
||||||
|
JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, material.sessionKey(), sessionType, clientPlatform),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession must be 200");
|
||||||
|
String sessionId = JsonParsers.sessionId(createResp);
|
||||||
|
assertNotNull(sessionId);
|
||||||
|
return new Session(sessionId, material.sessionKey(), material.sessionPrivKey(), storagePwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sessionLogin2Steps(WsSession ws,
|
||||||
|
Session s,
|
||||||
|
int sessionType,
|
||||||
|
String clientPlatform,
|
||||||
|
Duration t,
|
||||||
|
TestResult r) {
|
||||||
|
String chResp = ws.call("SessionChallenge", JsonBuilders.sessionChallenge(s.sessionId()), t);
|
||||||
|
assertEquals(200, JsonParsers.status(chResp));
|
||||||
|
String nonce = JsonParsers.sessionNonce(chResp);
|
||||||
|
assertNotNull(nonce);
|
||||||
|
|
||||||
|
String loginResp = ws.call(
|
||||||
|
"SessionLogin",
|
||||||
|
JsonBuilders.sessionLogin(s.sessionId(), s.sessionKey(), nonce, s.sessionPrivKey(), sessionType, clientPlatform),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(loginResp));
|
||||||
|
assertEquals(s.storagePwd(), JsonParsers.storagePwd(loginResp));
|
||||||
|
r.ok("SessionLogin OK for sessionType=" + sessionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertErrorFormat(String resp, String op, 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(code, JsonParsers.errorCode(resp), "Unexpected error code: " + 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 static void ensureUserSeeded() throws Exception {
|
||||||
|
if (SolanaUsersDAO.getInstance().existsByLogin(LOGIN)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SolanaUserEntry entry = new SolanaUserEntry();
|
||||||
|
entry.setLogin(LOGIN);
|
||||||
|
entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN));
|
||||||
|
entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN));
|
||||||
|
entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN));
|
||||||
|
entry.setDeviceKey(TestConfig.devicePublicKeyB64(LOGIN));
|
||||||
|
SolanaUsersDAO.getInstance().insert(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {}
|
||||||
|
private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import test.it.cases.IT_03_AddBlock_NoAuth;
|
|||||||
import test.it.cases.IT_04_UserParams_NoAuth;
|
import test.it.cases.IT_04_UserParams_NoAuth;
|
||||||
import test.it.cases.IT_05_UserConnections;
|
import test.it.cases.IT_05_UserConnections;
|
||||||
import test.it.cases.IT_06_ChannelsApi;
|
import test.it.cases.IT_06_ChannelsApi;
|
||||||
|
import test.it.cases.IT_07_EspPairing;
|
||||||
import test.it.cases.Seed_TestDataPopulation;
|
import test.it.cases.Seed_TestDataPopulation;
|
||||||
import test.it.utils.log.TestLog;
|
import test.it.utils.log.TestLog;
|
||||||
|
|
||||||
@ -61,9 +62,12 @@ public class IT_RunAllMain {
|
|||||||
String s6 = IT_06_ChannelsApi.run(); summaries.add(s6);
|
String s6 = IT_06_ChannelsApi.run(); summaries.add(s6);
|
||||||
if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||||||
|
|
||||||
String s7 = Seed_TestDataPopulation.run(); summaries.add(s7);
|
String s7 = IT_07_EspPairing.run(); summaries.add(s7);
|
||||||
if (s7.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
if (s7.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||||||
|
|
||||||
|
String s8 = Seed_TestDataPopulation.run(); summaries.add(s8);
|
||||||
|
if (s8.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||||||
|
|
||||||
return finish(summaries, failed);
|
return finish(summaries, failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -137,6 +137,10 @@ public final class JsonBuilders {
|
|||||||
// preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce
|
// preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce
|
||||||
|
|
||||||
public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) {
|
public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) {
|
||||||
|
return createAuthSessionV2(login, authNonce, storagePwd, sessionKey, 1, "IT");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey, int sessionType, String clientPlatform) {
|
||||||
long timeMs = System.currentTimeMillis();
|
long timeMs = System.currentTimeMillis();
|
||||||
|
|
||||||
byte[] devicePriv = TestConfig.getDevicePrivatKey(login);
|
byte[] devicePriv = TestConfig.getDevicePrivatKey(login);
|
||||||
@ -156,6 +160,8 @@ public final class JsonBuilders {
|
|||||||
"authNonce": "%s",
|
"authNonce": "%s",
|
||||||
"deviceKey": "%s",
|
"deviceKey": "%s",
|
||||||
"signatureB64": "%s",
|
"signatureB64": "%s",
|
||||||
|
"sessionType": %d,
|
||||||
|
"clientPlatform": "%s",
|
||||||
"clientInfo": "%s"
|
"clientInfo": "%s"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -168,6 +174,8 @@ public final class JsonBuilders {
|
|||||||
authNonce,
|
authNonce,
|
||||||
deviceKey,
|
deviceKey,
|
||||||
sigB64,
|
sigB64,
|
||||||
|
sessionType,
|
||||||
|
clientPlatform == null ? "" : clientPlatform,
|
||||||
TestConfig.TEST_CLIENT_INFO
|
TestConfig.TEST_CLIENT_INFO
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -192,6 +200,10 @@ public final class JsonBuilders {
|
|||||||
// preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce
|
// preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce
|
||||||
|
|
||||||
public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey) {
|
public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey) {
|
||||||
|
return sessionLogin(sessionId, sessionKey, nonce, sessionPrivKey, 1, "IT");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey, int sessionType, String clientPlatform) {
|
||||||
long timeMs = System.currentTimeMillis();
|
long timeMs = System.currentTimeMillis();
|
||||||
String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey);
|
String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey);
|
||||||
|
|
||||||
@ -203,12 +215,14 @@ public final class JsonBuilders {
|
|||||||
"payload": {
|
"payload": {
|
||||||
"sessionId": "%s",
|
"sessionId": "%s",
|
||||||
"sessionKey": "%s",
|
"sessionKey": "%s",
|
||||||
|
"sessionType": %d,
|
||||||
|
"clientPlatform": "%s",
|
||||||
"timeMs": %d,
|
"timeMs": %d,
|
||||||
"signatureB64": "%s",
|
"signatureB64": "%s",
|
||||||
"clientInfo": "%s"
|
"clientInfo": "%s"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".formatted(requestId, sessionId, sessionKey, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO);
|
""".formatted(requestId, sessionId, sessionKey, sessionType, clientPlatform == null ? "" : clientPlatform, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- ListSessions ----------------
|
// ---------------- ListSessions ----------------
|
||||||
@ -242,6 +256,96 @@ public final class JsonBuilders {
|
|||||||
""".formatted(requestId, sessionId, timeMs, signatureB64);
|
""".formatted(requestId, sessionId, timeMs, signatureB64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String upsertEspPairingSettings(boolean enabled, String passwordHash, int ttlSeconds) {
|
||||||
|
String requestId = TestIds.next("esp-set");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "UpsertEspPairingSettings",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {
|
||||||
|
"enabled": %s,
|
||||||
|
"passwordHash": "%s",
|
||||||
|
"ttlSeconds": %d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(requestId, enabled, passwordHash == null ? "" : passwordHash, ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String startEspPairing(String login,
|
||||||
|
String passwordHash,
|
||||||
|
String requesterSessionKey,
|
||||||
|
int requesterSessionType,
|
||||||
|
String requesterClientPlatform,
|
||||||
|
int payloadType) {
|
||||||
|
String requestId = TestIds.next("esp-start");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "StartEspPairing",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {
|
||||||
|
"login": "%s",
|
||||||
|
"passwordHash": "%s",
|
||||||
|
"requesterSessionKey": "%s",
|
||||||
|
"requesterSessionType": %d,
|
||||||
|
"requesterClientPlatform": "%s",
|
||||||
|
"payloadType": %d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(requestId, login, passwordHash, requesterSessionKey, requesterSessionType, requesterClientPlatform == null ? "" : requesterClientPlatform, payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String listEspPairingRequests() {
|
||||||
|
String requestId = TestIds.next("esp-list");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "ListEspPairingRequests",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
""".formatted(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String approveEspPairing(String pairingId, String encryptedPayload) {
|
||||||
|
String requestId = TestIds.next("esp-approve");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "ApproveEspPairing",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "%s",
|
||||||
|
"encryptedPayload": "%s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(requestId, pairingId, encryptedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String rejectEspPairing(String pairingId, String reason) {
|
||||||
|
String requestId = TestIds.next("esp-reject");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "RejectEspPairing",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "%s",
|
||||||
|
"reason": "%s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(requestId, pairingId, reason == null ? "" : reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getEspPairingStatus(String pairingId) {
|
||||||
|
String requestId = TestIds.next("esp-status");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "GetEspPairingStatus",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "%s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(requestId, pairingId);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- ListSubscribedChannels ----------------
|
// ---------------- ListSubscribedChannels ----------------
|
||||||
|
|
||||||
public static String listSubscribedChannels(String login) {
|
public static String listSubscribedChannels(String login) {
|
||||||
|
|||||||
@ -147,6 +147,19 @@ public final class JsonParsers {
|
|||||||
return getPayloadText(json, field);
|
return getPayloadText(json, field);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Boolean payloadBoolean(String json, String field) {
|
||||||
|
try {
|
||||||
|
JsonNode root = MAPPER.readTree(json);
|
||||||
|
JsonNode payload = root.get("payload");
|
||||||
|
if (payload != null && payload.has(field) && !payload.get(field).isNull()) {
|
||||||
|
return payload.get(field).asBoolean();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static List<String> sessionIds(String json) {
|
public static List<String> sessionIds(String json) {
|
||||||
List<String> res = new ArrayList<>();
|
List<String> res = new ArrayList<>();
|
||||||
try {
|
try {
|
||||||
@ -315,4 +328,19 @@ public final class JsonParsers {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String firstArrayItemText(String json, String arrayField, String field) {
|
||||||
|
try {
|
||||||
|
JsonNode root = MAPPER.readTree(json);
|
||||||
|
JsonNode payload = root.get("payload");
|
||||||
|
if (payload == null) return null;
|
||||||
|
JsonNode arr = payload.get(arrayField);
|
||||||
|
if (arr == null || !arr.isArray() || arr.isEmpty()) return null;
|
||||||
|
JsonNode item = arr.get(0);
|
||||||
|
if (item == null || !item.has(field) || item.get(field).isNull()) return null;
|
||||||
|
return item.get(field).asText();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.190
|
client.version=1.2.192
|
||||||
server.version=1.2.179
|
server.version=1.2.181
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user