Доработать UX и отмену pairing по коду
This commit is contained in:
parent
9fcdcd087b
commit
9a489801c5
@ -308,6 +308,7 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
|||||||
- `ListEspPairingRequests`
|
- `ListEspPairingRequests`
|
||||||
- `ApproveEspPairing`
|
- `ApproveEspPairing`
|
||||||
- `RejectEspPairing`
|
- `RejectEspPairing`
|
||||||
|
- `CancelEspPairing`
|
||||||
- `GetEspPairingStatus`
|
- `GetEspPairingStatus`
|
||||||
|
|
||||||
В этом потоке:
|
В этом потоке:
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
- `ListEspPairingRequests`
|
- `ListEspPairingRequests`
|
||||||
- `ApproveEspPairing`
|
- `ApproveEspPairing`
|
||||||
- `RejectEspPairing`
|
- `RejectEspPairing`
|
||||||
|
- `CancelEspPairing`
|
||||||
|
|
||||||
Анонимное новое устройство работает с двумя связанными операциями:
|
Анонимное новое устройство работает с двумя связанными операциями:
|
||||||
|
|
||||||
@ -261,7 +262,8 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
- `400 / BAD_SESSION_TYPE`
|
- `400 / BAD_SESSION_TYPE`
|
||||||
- `400 / BAD_PAYLOAD_TYPE`
|
- `400 / BAD_PAYLOAD_TYPE`
|
||||||
- `422 / PAIRING_NOT_AVAILABLE`
|
- `422 / PAIRING_NOT_AVAILABLE`
|
||||||
- `422 / PAIRING_PASSWORD_INVALID`
|
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
|
||||||
|
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
|
||||||
- `429 / PAIRING_RATE_LIMITED`
|
- `429 / PAIRING_RATE_LIMITED`
|
||||||
|
|
||||||
### 5.3. `ListEspPairingRequests`
|
### 5.3. `ListEspPairingRequests`
|
||||||
@ -388,4 +390,46 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
- `created`
|
- `created`
|
||||||
- `approved`
|
- `approved`
|
||||||
- `rejected`
|
- `rejected`
|
||||||
|
- `canceled`
|
||||||
- `expired`
|
- `expired`
|
||||||
|
|
||||||
|
### 5.7. `CancelEspPairing`
|
||||||
|
|
||||||
|
Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "CancelEspPairing",
|
||||||
|
"requestId": "esp-cancel-001",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "CancelEspPairing",
|
||||||
|
"requestId": "esp-cancel-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "base64url",
|
||||||
|
"state": "canceled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `400 / EMPTY_PAIRING_ID`
|
||||||
|
- `400 / EMPTY_REQUESTER_SESSION_KEY`
|
||||||
|
- `400 / BAD_REQUESTER_SESSION_KEY`
|
||||||
|
- `404 / PAIRING_NOT_FOUND`
|
||||||
|
- `422 / PAIRING_OF_ANOTHER_REQUESTER`
|
||||||
|
- `422 / PAIRING_NOT_PENDING`
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
||||||
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
||||||
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
||||||
|
| `CancelEspPairing` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства |
|
||||||
| `GetEspPairingStatus` | `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` | закрытие активной сессии |
|
||||||
|
|||||||
@ -18,6 +18,10 @@
|
|||||||
- отдельно проверить отклонение заявки и истечение TTL.
|
- отдельно проверить отклонение заявки и истечение TTL.
|
||||||
- при отклонении заявки убедиться, что на новом устройстве сразу исчезает карточка со старым 7-значным кодом и остаётся только сообщение об отклонении;
|
- при отклонении заявки убедиться, что на новом устройстве сразу исчезает карточка со старым 7-значным кодом и остаётся только сообщение об отклонении;
|
||||||
- убедиться, что экран `Войти` больше не показывает неработающую QR-заглушку сверху.
|
- убедиться, что экран `Войти` больше не показывает неработающую QR-заглушку сверху.
|
||||||
|
- убедиться, что при неверном pairing-пароле и при попытке ввести пароль там, где он не включён, пользователь видит одинаковую ошибку `Пароль подключения не подходит.`;
|
||||||
|
- убедиться, что без онлайн доверённой сессии новое устройство сразу получает явную ошибку и код вообще не создаётся;
|
||||||
|
- убедиться, что countdown под кодом убывает в реальном времени;
|
||||||
|
- убедиться, что кнопка `Отмена` на новом устройстве действительно снимает заявку и она пропадает у доверённого устройства без ожидания TTL.
|
||||||
|
|
||||||
- ожидаемый результат:
|
- ожидаемый результат:
|
||||||
- новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить;
|
- новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить;
|
||||||
|
|||||||
@ -199,6 +199,22 @@ public final class EspPairingRequestsDAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void markCanceled(String pairingId, String rejectReason, long updatedAtMs) throws SQLException {
|
||||||
|
updateSimple(pairingId, """
|
||||||
|
UPDATE esp_pairing_requests
|
||||||
|
SET status = 'canceled',
|
||||||
|
reject_reason = ?,
|
||||||
|
approved_by_session_id = NULL,
|
||||||
|
encrypted_payload = NULL,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE pairing_id = ?
|
||||||
|
""", ps -> {
|
||||||
|
ps.setString(1, rejectReason);
|
||||||
|
ps.setLong(2, updatedAtMs);
|
||||||
|
ps.setString(3, pairingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public int expirePending(long nowMs) throws SQLException {
|
public int expirePending(long nowMs) throws SQLException {
|
||||||
try (Connection c = db.getConnection();
|
try (Connection c = db.getConnection();
|
||||||
PreparedStatement ps = c.prepareStatement("""
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handle
|
|||||||
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_ListEspPairingRequests_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_CancelEspPairing_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
|
||||||
|
|
||||||
@ -27,6 +28,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRe
|
|||||||
|
|
||||||
// --- 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_ApproveEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_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_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;
|
||||||
@ -138,6 +140,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()),
|
Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()),
|
||||||
Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()),
|
Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()),
|
||||||
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
|
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
|
||||||
|
Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()),
|
||||||
Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()),
|
Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()),
|
||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
@ -202,6 +205,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class),
|
Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class),
|
||||||
Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class),
|
Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class),
|
||||||
Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class),
|
Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class),
|
||||||
|
Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class),
|
||||||
Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class),
|
Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class),
|
||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
|
|||||||
@ -27,6 +27,7 @@ final class EspPairingSupport {
|
|||||||
static final String STATE_CREATED = "created";
|
static final String STATE_CREATED = "created";
|
||||||
static final String STATE_APPROVED = "approved";
|
static final String STATE_APPROVED = "approved";
|
||||||
static final String STATE_REJECTED = "rejected";
|
static final String STATE_REJECTED = "rejected";
|
||||||
|
static final String STATE_CANCELED = "canceled";
|
||||||
static final String STATE_EXPIRED = "expired";
|
static final String STATE_EXPIRED = "expired";
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
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_CancelEspPairing_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
|
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_CancelEspPairing_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_CancelEspPairing_Request req = (Net_CancelEspPairing_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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!requesterSessionKey.equals(row.getRequesterSessionKey())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_REQUESTER", "Нельзя отменять pairing другого устройства");
|
||||||
|
}
|
||||||
|
if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created");
|
||||||
|
}
|
||||||
|
|
||||||
|
EspPairingRequestsDAO.getInstance().markCanceled(pairingId, "canceled_by_requester", now);
|
||||||
|
|
||||||
|
Net_CancelEspPairing_Response resp = new Net_CancelEspPairing_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setPairingId(pairingId);
|
||||||
|
resp.setState(EspPairingSupport.STATE_CANCELED);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -83,15 +83,23 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
String configuredPasswordHash = settings.getPasswordHash() == null ? "" : settings.getPasswordHash().trim();
|
String configuredPasswordHash = settings.getPasswordHash() == null ? "" : settings.getPasswordHash().trim();
|
||||||
boolean requiresPassword = !configuredPasswordHash.isBlank();
|
boolean requiresPassword = !configuredPasswordHash.isBlank();
|
||||||
if (requiresPassword && !configuredPasswordHash.equals(passwordHash)) {
|
boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank();
|
||||||
|
if ((requiresPassword && !configuredPasswordHash.equals(passwordHash))
|
||||||
|
|| (!requiresPassword && suppliedPassword)) {
|
||||||
return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль");
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль");
|
||||||
}
|
}
|
||||||
if (!requiresPassword && passwordHash != null && !passwordHash.isBlank()) {
|
|
||||||
passwordHash = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
||||||
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds());
|
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds());
|
||||||
|
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||||
|
if (approverConnections.isEmpty()) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
422,
|
||||||
|
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
|
||||||
|
"Нет ни одной активной доверенной сессии пользователя в сети"
|
||||||
|
);
|
||||||
|
}
|
||||||
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||||
canonicalLogin,
|
canonicalLogin,
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
@ -117,7 +125,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
entry.setDeliveredToHomeserver(false);
|
entry.setDeliveredToHomeserver(false);
|
||||||
EspPairingRequestsDAO.getInstance().insert(entry);
|
EspPairingRequestsDAO.getInstance().insert(entry);
|
||||||
|
|
||||||
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
|
||||||
boolean delivered = false;
|
boolean delivered = false;
|
||||||
for (ConnectionContext targetCtx : approverConnections) {
|
for (ConnectionContext targetCtx : approverConnections) {
|
||||||
String eventId = NetIdGenerator.eventId("pair");
|
String eventId = NetIdGenerator.eventId("pair");
|
||||||
|
|||||||
@ -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_CancelEspPairing_Request extends Net_Request {
|
||||||
|
private String pairingId;
|
||||||
|
private String requesterSessionKey;
|
||||||
|
|
||||||
|
public String getPairingId() {
|
||||||
|
return pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairingId(String pairingId) {
|
||||||
|
this.pairingId = pairingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterSessionKey() {
|
||||||
|
return requesterSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterSessionKey(String requesterSessionKey) {
|
||||||
|
this.requesterSessionKey = requesterSessionKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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_CancelEspPairing_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -94,6 +94,44 @@ public class IT_07_EspPairing {
|
|||||||
);
|
);
|
||||||
assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200");
|
assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200");
|
||||||
|
|
||||||
|
String startWrongPasswordResp = requesterWs.call(
|
||||||
|
"StartEspPairing",
|
||||||
|
JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertErrorFormat(startWrongPasswordResp, "StartEspPairing", "PAIRING_PASSWORD_INVALID");
|
||||||
|
|
||||||
|
SessionMaterial cancelMaterial = newSessionMaterial();
|
||||||
|
String startCancelableResp = requesterWs.call(
|
||||||
|
"StartEspPairing",
|
||||||
|
JsonBuilders.startEspPairing(LOGIN, "", cancelMaterial.sessionKey(), 1, "Android", 1),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(startCancelableResp), "StartEspPairing for cancel must be 200");
|
||||||
|
String cancelPairingId = JsonParsers.payloadText(startCancelableResp, "pairingId");
|
||||||
|
String cancelResp = requesterWs.call(
|
||||||
|
"CancelEspPairing",
|
||||||
|
JsonBuilders.cancelEspPairing(cancelPairingId, cancelMaterial.sessionKey()),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(cancelResp), "CancelEspPairing must be 200");
|
||||||
|
assertEquals("canceled", JsonParsers.payloadText(cancelResp, "state"));
|
||||||
|
|
||||||
|
String closeResp = clientWs.call(
|
||||||
|
"CloseActiveSession",
|
||||||
|
JsonBuilders.closeActiveSession(clientSession.sessionId(), 0, ""),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession must be 200");
|
||||||
|
|
||||||
|
SessionMaterial requesterOfflineMaterial = newSessionMaterial();
|
||||||
|
String startOfflineResp = requesterWs.call(
|
||||||
|
"StartEspPairing",
|
||||||
|
JsonBuilders.startEspPairing(LOGIN, "", requesterOfflineMaterial.sessionKey(), 1, "Android", 1),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
assertErrorFormat(startOfflineResp, "StartEspPairing", "PAIRING_NO_TRUSTED_SESSION_ONLINE");
|
||||||
|
|
||||||
String forbiddenResp = requesterWs.call(
|
String forbiddenResp = requesterWs.call(
|
||||||
"ListEspPairingRequests#anonymous",
|
"ListEspPairingRequests#anonymous",
|
||||||
JsonBuilders.listEspPairingRequests(),
|
JsonBuilders.listEspPairingRequests(),
|
||||||
|
|||||||
@ -333,6 +333,20 @@ public final class JsonBuilders {
|
|||||||
""".formatted(requestId, pairingId, reason == null ? "" : reason);
|
""".formatted(requestId, pairingId, reason == null ? "" : reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String cancelEspPairing(String pairingId, String requesterSessionKey) {
|
||||||
|
String requestId = TestIds.next("esp-cancel");
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op": "CancelEspPairing",
|
||||||
|
"requestId": "%s",
|
||||||
|
"payload": {
|
||||||
|
"pairingId": "%s",
|
||||||
|
"requesterSessionKey": "%s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(requestId, pairingId, requesterSessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
public static String getEspPairingStatus(String pairingId) {
|
public static String getEspPairingStatus(String pairingId) {
|
||||||
String requestId = TestIds.next("esp-status");
|
String requestId = TestIds.next("esp-status");
|
||||||
return """
|
return """
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.199
|
client.version=1.2.200
|
||||||
server.version=1.2.188
|
server.version=1.2.189
|
||||||
|
|||||||
@ -42,7 +42,7 @@ import * as topupView from './pages/topup-view.js';
|
|||||||
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
||||||
import * as loginView from './pages/login-view.js?v=202606150110';
|
import * as loginView from './pages/login-view.js?v=202606150110';
|
||||||
import * as loginCameraView from './pages/login-camera-view.js';
|
import * as loginCameraView from './pages/login-camera-view.js';
|
||||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150110';
|
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150215';
|
||||||
import * as loginPasswordView from './pages/login-password-view.js';
|
import * as loginPasswordView from './pages/login-password-view.js';
|
||||||
import * as keyStorageView from './pages/key-storage-view.js';
|
import * as keyStorageView from './pages/key-storage-view.js';
|
||||||
|
|
||||||
|
|||||||
@ -38,10 +38,11 @@ function codeCardHtml() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpiresAt(ms) {
|
function formatRemaining(ms) {
|
||||||
const ts = Number(ms || 0);
|
const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
||||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
const minutes = Math.floor(safe / 60);
|
||||||
return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const seconds = safe % 60;
|
||||||
|
return `${minutes} мин ${seconds} сек`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
|
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
|
||||||
@ -56,8 +57,10 @@ export function render({ navigate }) {
|
|||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
let pollTimer = 0;
|
let pollTimer = 0;
|
||||||
|
let countdownTimer = 0;
|
||||||
let activePairingId = '';
|
let activePairingId = '';
|
||||||
let requesterMaterial = null;
|
let requesterMaterial = null;
|
||||||
|
let activeExpiresAtMs = 0;
|
||||||
let isDisposed = false;
|
let isDisposed = false;
|
||||||
|
|
||||||
clearAuthMessages();
|
clearAuthMessages();
|
||||||
@ -73,8 +76,8 @@ export function render({ navigate }) {
|
|||||||
formCard.className = 'card stack';
|
formCard.className = 'card stack';
|
||||||
formCard.innerHTML = `
|
formCard.innerHTML = `
|
||||||
<label class="stack">
|
<label class="stack">
|
||||||
<span class="field-label">Логин</span>
|
<span class="field-label">Введите логин</span>
|
||||||
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="@login" value="${String(state.loginDraft.login || '')}" />
|
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" />
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
<input type="checkbox" id="pair-use-password" />
|
<input type="checkbox" id="pair-use-password" />
|
||||||
@ -85,7 +88,7 @@ export function render({ navigate }) {
|
|||||||
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
||||||
</label>
|
</label>
|
||||||
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
|
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
|
||||||
<p class="meta-muted" id="pair-mode-hint">Сначала вводится ваш логин. Если на доверённом устройстве включён доп. пароль, включите галочку и введите его. После этого появится 7-значный код для подтверждения.</p>
|
<p class="meta-muted" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
@ -106,13 +109,18 @@ export function render({ navigate }) {
|
|||||||
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
||||||
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
||||||
const expireHintEl = resultWrap.querySelector('#pairing-expire-hint');
|
const expireHintEl = resultWrap.querySelector('#pairing-expire-hint');
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'ghost-btn';
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.textContent = 'Отмена';
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
|
||||||
const syncPasswordUi = () => {
|
const syncPasswordUi = () => {
|
||||||
const usePassword = !!usePasswordInput.checked;
|
const usePassword = !!usePasswordInput.checked;
|
||||||
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
||||||
modeHintEl.textContent = usePassword
|
modeHintEl.textContent = usePassword
|
||||||
? 'Введите логин и доп. пароль, который был задан на доверённом устройстве.'
|
? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.'
|
||||||
: 'Введите логин. Если на доверённом устройстве пароль не задан, вход пойдёт без доп. пароля.';
|
: 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.';
|
||||||
if (!usePassword) {
|
if (!usePassword) {
|
||||||
passwordInput.value = '';
|
passwordInput.value = '';
|
||||||
}
|
}
|
||||||
@ -125,6 +133,45 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopCountdown = () => {
|
||||||
|
if (countdownTimer) {
|
||||||
|
window.clearInterval(countdownTimer);
|
||||||
|
countdownTimer = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const leftMs = activeExpiresAtMs - Date.now();
|
||||||
|
if (leftMs <= 0) {
|
||||||
|
stopPolling();
|
||||||
|
stopCountdown();
|
||||||
|
activePairingId = '';
|
||||||
|
activeExpiresAtMs = 0;
|
||||||
|
startBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||||
|
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expireHintEl.textContent = `Код действителен ещё ${formatRemaining(leftMs)}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCountdown = (expiresAtMs) => {
|
||||||
|
activeExpiresAtMs = Number(expiresAtMs || 0);
|
||||||
|
stopCountdown();
|
||||||
|
updateCountdown();
|
||||||
|
countdownTimer = window.setInterval(updateCountdown, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearActivePairing = () => {
|
||||||
|
stopPolling();
|
||||||
|
stopCountdown();
|
||||||
|
activePairingId = '';
|
||||||
|
activeExpiresAtMs = 0;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||||
|
};
|
||||||
|
|
||||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
@ -159,6 +206,7 @@ export function render({ navigate }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stateValue === 'approved') {
|
if (stateValue === 'approved') {
|
||||||
|
stopCountdown();
|
||||||
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
|
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
|
||||||
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial);
|
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial);
|
||||||
if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') {
|
if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') {
|
||||||
@ -168,18 +216,20 @@ export function render({ navigate }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stateValue === 'rejected') {
|
if (stateValue === 'rejected') {
|
||||||
stopPolling();
|
clearActivePairing();
|
||||||
activePairingId = '';
|
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
|
||||||
setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error');
|
setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stateValue === 'expired') {
|
if (stateValue === 'canceled') {
|
||||||
stopPolling();
|
clearActivePairing();
|
||||||
activePairingId = '';
|
startBtn.disabled = false;
|
||||||
|
setStatus(status, 'Ожидание подключения отменено.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stateValue === 'expired') {
|
||||||
|
clearActivePairing();
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
|
||||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -215,9 +265,7 @@ export function render({ navigate }) {
|
|||||||
setAuthError('');
|
setAuthError('');
|
||||||
setAuthInfo('');
|
setAuthInfo('');
|
||||||
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
|
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
|
||||||
stopPolling();
|
clearActivePairing();
|
||||||
activePairingId = '';
|
|
||||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
@ -247,10 +295,9 @@ export function render({ navigate }) {
|
|||||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
||||||
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
||||||
expireHintEl.textContent = payload?.expiresAtMs
|
|
||||||
? `Код действует до ${formatExpiresAt(payload.expiresAtMs)}.`
|
|
||||||
: '';
|
|
||||||
resultWrap.style.display = '';
|
resultWrap.style.display = '';
|
||||||
|
cancelBtn.style.display = '';
|
||||||
|
startCountdown(payload?.expiresAtMs);
|
||||||
state.loginDraft.login = login;
|
state.loginDraft.login = login;
|
||||||
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
||||||
schedulePoll();
|
schedulePoll();
|
||||||
@ -264,11 +311,40 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', async () => {
|
||||||
|
if (!activePairingId || !requesterMaterial?.sessionKey) {
|
||||||
|
clearActivePairing();
|
||||||
|
startBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await authService.cancelEspPairing(activePairingId, requesterMaterial.sessionKey);
|
||||||
|
clearActivePairing();
|
||||||
|
startBtn.disabled = false;
|
||||||
|
setStatus(status, 'Ожидание подключения отменено.', 'info');
|
||||||
|
} catch (error) {
|
||||||
|
const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.');
|
||||||
|
setAuthError(message);
|
||||||
|
setStatus(status, message, 'error');
|
||||||
|
} finally {
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = activePairingId ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
isDisposed = true;
|
isDisposed = true;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
stopCountdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resultActions = document.createElement('div');
|
||||||
|
resultActions.className = 'row';
|
||||||
|
resultActions.append(cancelBtn);
|
||||||
|
resultWrap.append(resultActions);
|
||||||
|
|
||||||
screen.append(formCard, status, resultWrap);
|
screen.append(formCard, status, resultWrap);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1020,6 +1020,15 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelEspPairing(pairingId, requesterSessionKey) {
|
||||||
|
const response = await this.ws.request('CancelEspPairing', {
|
||||||
|
pairingId: String(pairingId || '').trim(),
|
||||||
|
requesterSessionKey: String(requesterSessionKey || '').trim(),
|
||||||
|
});
|
||||||
|
if (response.status !== 200) throw opError('CancelEspPairing', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getEspPairingStatus(pairingId) {
|
async getEspPairingStatus(pairingId) {
|
||||||
const response = await this.ws.request('GetEspPairingStatus', {
|
const response = await this.ws.request('GetEspPairingStatus', {
|
||||||
pairingId: String(pairingId || '').trim(),
|
pairingId: String(pairingId || '').trim(),
|
||||||
|
|||||||
@ -50,6 +50,14 @@ export function toUserMessage(error, fallback = 'Действие не выпо
|
|||||||
return 'Пользователь не найден. Проверьте логин.';
|
return 'Пользователь не найден. Проверьте логин.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
|
||||||
|
return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'PAIRING_PASSWORD_INVALID') {
|
||||||
|
return 'Пароль подключения не подходит.';
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
code === 'PREMIUMLOGIN' ||
|
code === 'PREMIUMLOGIN' ||
|
||||||
text.includes('premiumlogin') ||
|
text.includes('premiumlogin') ||
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user