diff --git a/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java b/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java index eb89cb5..f219fea 100644 --- a/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java +++ b/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java @@ -125,7 +125,7 @@ public final class ActiveSessionsDAO { /** * Обновить только lastAuthirificatedAtMs для конкретной сессии. - * (оставляю для совместимости, вдруг ещё где-то используется) + * (оставлено для совместимости) */ public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException { String sql = """ diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java index d8cc255..2649e40 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java @@ -12,7 +12,7 @@ public class ConnectionContext { // Статусы аутентификации public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован - public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // получен AuthChallenge, ждём CreateAuthSession + public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // получен AuthChallenge public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь // Полный пользователь из БД (solana_users) diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 47a253d..b605987 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -1,15 +1,17 @@ package server.logic.ws_protocol.JSON; -import server.logic.ws_protocol.JSON.entyties.*; +import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Auth.Net_AuthChallenge_Request; import server.logic.ws_protocol.JSON.entyties.Auth.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.entyties.Auth.Net_RefreshSession_Request; -import server.logic.ws_protocol.JSON.handlers.*; +import server.logic.ws_protocol.JSON.entyties.Auth.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.entyties.tempToTest.Net_AddUser_Request; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_RefreshSession_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; -import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler; import java.util.Map; @@ -25,18 +27,20 @@ import java.util.Map; public final class JsonHandlerRegistry { private static final Map HANDLERS = Map.of( - "RefreshSession", new Net_RefreshSession_Handler(), - "AddUser", new Net_AddUser_Handler(), - "AuthChallenge", new Net_AuthChallenge_Handler(), - "CreateAuthSession", new Net_CreateAuthSession__Handler() + "RefreshSession", new Net_RefreshSession_Handler(), + "AddUser", new Net_AddUser_Handler(), + "AuthChallenge", new Net_AuthChallenge_Handler(), + "CreateAuthSession", new Net_CreateAuthSession__Handler(), + "CloseActiveSession", new Net_CloseActiveSession_Handler() // сюда потом добавишь другие операции ); private static final Map> REQUEST_TYPES = Map.of( - "RefreshSession", Net_RefreshSession_Request.class, - "AddUser", Net_AddUser_Request.class, - "AuthChallenge", Net_AuthChallenge_Request.class, - "CreateAuthSession", Net_CreateAuthSession_Request.class + "RefreshSession", Net_RefreshSession_Request.class, + "AddUser", Net_AddUser_Request.class, + "AuthChallenge", Net_AuthChallenge_Request.class, + "CreateAuthSession", Net_CreateAuthSession_Request.class, + "CloseActiveSession", Net_CloseActiveSession_Request.class ); private JsonHandlerRegistry() { @@ -50,4 +54,4 @@ public final class JsonHandlerRegistry { public static Map> getRequestTypes() { return REQUEST_TYPES; } -} +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Auth/Net_CloseActiveSession_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Auth/Net_CloseActiveSession_Request.java new file mode 100644 index 0000000..b647083 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Auth/Net_CloseActiveSession_Request.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.entyties.Auth; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запрос CloseActiveSession — закрытие активной сессии пользователя. + * + * Допустимые режимы: + * + * 1) Пользователь уже авторизован (AUTH_STATUS_USER): + * - поле sessionId: + * * если заполнено — закрывается указанная сессия пользователя; + * * если пустое — закрывается текущая авторизованная сессия + * (та, в рамках которой выполняется запрос). + * - поля timeMs и signatureB64 могут быть пустыми и игнорируются. + * + * 2) Пользователь в статусе AUTH_STATUS_AUTH_IN_PROGRESS: + * - требуется дополнительно подтвердить владение ключом: + * * timeMs — время на клиенте (мс с 1970-01-01), + * * signatureB64 — подпись Ed25519 над строкой + * "AUTHORIFICATED:" + timeMs + authNonce. + * - authNonce берётся из шага 1 (AuthChallenge) и хранится в ctx.authNonce. + * - если подпись корректна, сервер закрывает указанную sessionId (или текущую, + * если sessionId не задана) и рвёт соответствующее WebSocket-подключение. + */ +public class Net_CloseActiveSession_Request extends Net_Request { + + /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ + private String sessionId; + + /** Время на стороне клиента (мс с 1970-01-01). Используется при AUTH_IN_PROGRESS. */ + private long timeMs; + + /** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */ + private String signatureB64; + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public long getTimeMs() { + return timeMs; + } + + public void setTimeMs(long timeMs) { + this.timeMs = timeMs; + } + + public String getSignatureB64() { + return signatureB64; + } + + public void setSignatureB64(String signatureB64) { + this.signatureB64 = signatureB64; + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Auth/Net_CloseActiveSession_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Auth/Net_CloseActiveSession_Response.java new file mode 100644 index 0000000..1b5792e --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/Auth/Net_CloseActiveSession_Response.java @@ -0,0 +1,17 @@ +package server.logic.ws_protocol.JSON.entyties.Auth; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** + * Ответ на CloseActiveSession. + * + * При успехе: + * - status = 200; + * - payload = {}. + * + * Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии) + * или чуть позже (для текущей сессии) после отправки ответа. + */ +public class Net_CloseActiveSession_Response extends Net_Response { + // Дополнительных полей пока не требуется. +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java new file mode 100644 index 0000000..de1c55e --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java @@ -0,0 +1,258 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Auth.Net_CloseActiveSession_Request; +import server.logic.ws_protocol.JSON.entyties.Auth.Net_CloseActiveSession_Response; +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.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import server.ws.WsConnectionUtils; +import shine.db.dao.ActiveSessionsDAO; +import shine.db.entities.ActiveSession; +import shine.db.entities.SolanaUser; + +import java.sql.SQLException; + +/** + * Хэндлер CloseActiveSession. + * + * Назначение: + * - закрыть одну из активных сессий пользователя: + * * либо явно указанную в sessionId, + * * либо текущую (если sessionId не задана). + * + * Допустимые состояния: + * - AUTH_STATUS_USER: + * * timeMs / signatureB64 могут быть пустыми. + * * Достаточно факта текущей авторизации. + * + * - AUTH_STATUS_AUTH_IN_PROGRESS: + * * требуется проверка подписи Ed25519 над строкой + * "AUTHORIFICATED:" + timeMs + authNonce + * (authNonce взят на шаге AuthChallenge и хранится в ctx.authNonce). + * * Если подпись корректна, можно закрывать сессию даже до полноценной + * установки новой сессии. + * + * Закрытие: + * - запись ActiveSession удаляется из БД; + * - если по этой sessionId есть активное WebSocket-подключение: + * * если это ДРУГОЕ подключение — оно закрывается сразу; + * * если это ТЕКУЩЕЕ подключение — сначала отправляется ответ 200, + * а закрытие выполняется в отдельном потоке с небольшой задержкой. + */ +public class Net_CloseActiveSession_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; + + if (ctx == null || ctx.getSolanaUser() == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "NOT_AUTHENTICATED", + "Операция доступна только в состоянии авторизации или авторификации" + ); + } + + SolanaUser user = ctx.getSolanaUser(); + long currentLoginId = user.getLoginId(); + + int authStatus = ctx.getAuthenticationStatus(); + if (authStatus != ConnectionContext.AUTH_STATUS_USER + && authStatus != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { + + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "BAD_AUTH_STATUS", + "Операция CloseActiveSession недоступна в текущем статусе аутентификации" + ); + } + + // Если мы ещё на шаге AUTH_IN_PROGRESS — проверяем подпись + if (authStatus == ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { + String authNonce = ctx.getAuthNonce(); + if (authNonce == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "NO_STEP1_CONTEXT", + "Шаг 1 авторизации не был корректно выполнен для данного соединения" + ); + } + + long timeMs = req.getTimeMs(); + String signatureB64 = req.getSignatureB64(); + + if (signatureB64 == null || signatureB64.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_SIGNATURE", + "Подпись обязательна при статусе AUTH_IN_PROGRESS" + ); + } + + long nowMs = System.currentTimeMillis(); + long diff = Math.abs(nowMs - timeMs); + if (diff > Net_CreateAuthSession__Handler.ALLOWED_SKEW_MS) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "TIME_SKEW", + "Время клиента отличается от сервера более чем на 30 секунд" + ); + } + + boolean sigOk; + try { + sigOk = Net_CreateAuthSession__Handler.verifyAuthorificatedSignature( + user, + authNonce, + timeMs, + signatureB64 + ); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", + "Некорректный формат Base64 для ключа или подписи" + ); + } + + if (!sigOk) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "BAD_SIGNATURE", + "Подпись не прошла проверку" + ); + } + } + + // Определяем, какую sessionId закрывать + String targetSessionId = req.getSessionId(); + if (targetSessionId == null || targetSessionId.isBlank()) { + // Если sessionId не передана — берём текущую активную + if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { + targetSessionId = ctx.getActiveSession().getSessionId(); + } else if (ctx.getSessionId() != null) { + targetSessionId = ctx.getSessionId(); + } else { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "NO_SESSION_TO_CLOSE", + "Не удалось определить, какую сессию нужно закрыть" + ); + } + } + + ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance(); + ActiveSession targetSession; + try { + targetSession = sessionsDao.getBySessionId(targetSessionId); + } catch (SQLException e) { + log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "DB_ERROR", + "Ошибка доступа к базе данных при поиске сессии" + ); + } + + if (targetSession == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "SESSION_NOT_FOUND", + "Сессия для закрытия не найдена" + ); + } + + if (targetSession.getLoginId() != currentLoginId) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "SESSION_OF_ANOTHER_USER", + "Нельзя закрывать сессию другого пользователя" + ); + } + + boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); + + // Пытаемся удалить сессию из БД и закрыть соответствующее подключение + closeActiveSession(targetSessionId, ctx, isCurrentSession); + + // Ответ OK (payload станет {} в JsonInboundProcessor) + Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + // Для текущей сессии WebSocket будет закрыт чуть позже в отдельном потоке, + // чтобы этот ответ успел уйти. + return resp; + } + + /** + * Закрытие активной сессии: + * - удаление записи из БД; + * - закрытие WebSocket-подключения, если оно существует. + * + * @param targetSessionId идентификатор сессии, которую надо закрыть + * @param currentCtx контекст текущего подключения (которое вызвало запрос) + * @param isCurrentSession true, если закрывается "эта же" сессия + */ + private void closeActiveSession(String targetSessionId, + ConnectionContext currentCtx, + boolean isCurrentSession) { + + ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance(); + try { + sessionsDao.deleteBySessionId(targetSessionId); + } catch (SQLException e) { + log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); + // Логируем, но считаем, что для клиента сессия всё равно должна быть недействительна. + } + + ConnectionContext ctxToClose = + ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); + + if (ctxToClose == null) { + return; + } + + if (isCurrentSession && ctxToClose == currentCtx) { + // Это текущее подключение: закрываем после отправки ответа. + new Thread(() -> { + try { + Thread.sleep(50); // небольшая пауза, чтобы ответ ушёл + } catch (InterruptedException ignored) { + } + WsConnectionUtils.closeConnection( + ctxToClose, + 4000, + "Session closed by client via CloseActiveSession" + ); + }, "CloseSession-" + targetSessionId).start(); + } else { + // Другая сессия — можно закрыть сразу + WsConnectionUtils.closeConnection( + ctxToClose, + 4000, + "Session closed by client via CloseActiveSession" + ); + } + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index 916a099..00847ec 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -54,7 +54,39 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); private static final SecureRandom RANDOM = new SecureRandom(); - private static final long ALLOWED_SKEW_MS = 30_000L; + + /** Допустимое расхождение времени клиента и сервера (мс). */ + public static final long ALLOWED_SKEW_MS = 30_000L; + + /** + * Общая проверка подписи Ed25519 над строкой: + * "AUTHORIFICATED:" + timeMs + authNonce. + * + * Используется и в CreateAuthSession, и в CloseActiveSession (для статуса AUTH_IN_PROGRESS). + * + * @param user пользователь (используется deviceKey) + * @param authNonce одноразовый nonce из шага 1 + * @param timeMs время на стороне клиента + * @param signatureB64 подпись в base64 + * @return true — подпись корректна; false — подпись не проходит верификацию + * @throws IllegalArgumentException при некорректном base64 ключа/подписи + */ + public static boolean verifyAuthorificatedSignature( + SolanaUser user, + String authNonce, + long timeMs, + String signatureB64 + ) throws IllegalArgumentException { + + String pubKeyB64 = user.getDeviceKey(); + byte[] publicKey32 = Ed25519Util.keyFromBase64(pubKeyB64); + byte[] signature64 = Base64.getDecoder().decode(signatureB64); + + String preimageStr = "AUTHORIFICATED:" + timeMs + authNonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } @Override public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { @@ -147,11 +179,13 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - byte[] publicKey32; - byte[] signature64; + // --- authNonce (challenge) мы сохранили в ctx.authNonce на шаге 1 --- + String authNonce = ctx.getAuthNonce(); + + // --- проверяем подпись через общий метод --- + boolean sigOk; try { - publicKey32 = Ed25519Util.keyFromBase64(pubKeyB64); - signature64 = Base64.getDecoder().decode(signatureB64); + sigOk = verifyAuthorificatedSignature(user, authNonce, timeMs, signatureB64); } catch (IllegalArgumentException ex) { Net_Response err = NetExceptionResponseFactory.error( req, @@ -163,14 +197,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - // --- authNonce (challenge) мы сохранили в ctx.authNonce на шаге 1 --- - String authNonce = ctx.getAuthNonce(); - - // --- собираем строку для подписи: "AUTHORIFICATED:" + timeMs + authNonce --- - String preimageStr = "AUTHORIFICATED:" + timeMs + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - boolean sigOk = Ed25519Util.verify(preimage, signature64, publicKey32); if (!sigOk) { Net_Response err = NetExceptionResponseFactory.error( req, diff --git a/src/main/java/Test/Test_AddUser_and_Authorification.java b/src/main/java/Test/Test_AddUser_and_Authorification.java index 8ec802b..4a172fe 100644 --- a/src/main/java/Test/Test_AddUser_and_Authorification.java +++ b/src/main/java/Test/Test_AddUser_and_Authorification.java @@ -31,12 +31,22 @@ import java.util.concurrent.CountDownLatch; * 4) Новое подключение: * - отправляем RefreshSession с тем же sessionId, * но заведомо неверным sessionPwd - * (в консоль пишем: ожидаем ОТРИЦАТЕЛЬНЫЙ ответ). + * (ожидаем ОТРИЦАТЕЛЬНЫЙ ответ: status != 200, + * code = SESSION_PWD_MISMATCH). * * 5) Ещё одно новое подключение: * - отправляем RefreshSession с sessionId * и корректным sessionPwd - * (в консоль пишем: ожидаем УСПЕШНЫЙ ответ). + * (ожидаем УСПЕШНЫЙ ответ: status=200, + * storagePwd совпадает с тем, что отправляли на шаге 3). + * + * В ЭТОМ ЖЕ подключении: + * - вызываем CloseActiveSession для этой sessionId; + * ждём 200 (успешное закрытие сессии). + * + * 6) Новое подключение: + * - снова пытаемся сделать RefreshSession по той же sessionId/sessionPwd; + * ожидаем ошибку: status != 200, code = SESSION_NOT_FOUND. */ public class Test_AddUser_and_Authorification { @@ -51,7 +61,7 @@ public class Test_AddUser_and_Authorification { private static final long TEST_BCH_ID = 4222L; private static final int TEST_BCH_LIMIT = 1_000_000; - // Краткая строка clientInfo, которую клиент шлёт на шаге CreateAuthSession + // Краткая строка clientInfo, которую клиент шлёт на шаге CreateAuthSession и RefreshSession private static final String TEST_CLIENT_INFO = "JavaTestClient/1.0"; // --- Тестовые пары ключей --- @@ -87,7 +97,7 @@ public class Test_AddUser_and_Authorification { /** sessionPwd (секрет сессии), выданный на шаге CreateAuthSession. */ private static String GLOBAL_SESSION_PWD; - /** storagePwd, который мы отправили при CreateAuthSession (для информации). */ + /** storagePwd, который мы отправили при CreateAuthSession. */ private static String GLOBAL_STORAGE_PWD_SENT; public static void main(String[] args) throws Exception { @@ -99,8 +109,11 @@ public class Test_AddUser_and_Authorification { // Сценарий 2: новое подключение, RefreshSession с неверным sessionPwd runScenario_RefreshSession_WrongPwd(); - // Сценарий 3: новое подключение, RefreshSession с корректным sessionPwd - runScenario_RefreshSession_CorrectPwd(); + // Сценарий 3: новое подключение, RefreshSession с корректным sessionPwd + CloseActiveSession + runScenario_RefreshSession_CorrectPwd_And_Close(); + + // Сценарий 4: новое подключение, RefreshSession после закрытия сессии + runScenario_RefreshSession_AfterClose(); System.out.println("Все тесты завершены, выходим."); } @@ -169,19 +182,50 @@ public class Test_AddUser_and_Authorification { System.out.println(message); System.out.println("-----------------------------------------------------"); - // Шаг 2: получаем authNonce - if (step == 1) { - GLOBAL_AUTH_NONCE = extractAuthNonce(message); - System.out.println("🔑 [S1] Извлечён authNonce: " + GLOBAL_AUTH_NONCE); - } - - // Шаг 3: получаем sessionId и sessionPwd - if (step == 2) { - GLOBAL_SESSION_ID = extractSessionId(message); - GLOBAL_SESSION_PWD = extractSessionPwd(message); - System.out.println("🆔 [S1] Извлечён sessionId: " + GLOBAL_SESSION_ID); - System.out.println("🔐 [S1] Извлечён sessionPwd: " + GLOBAL_SESSION_PWD); - System.out.println(" (Эти sessionId и sessionPwd понадобятся в сценариях 2 и 3)"); + int status = extractStatus(message); + switch (step) { + case 0 -> { + // AddUser: ждём status=200 + if (status == 200) { + printOk("[S1] AddUser", "Пользователь успешно добавлен (status=200)"); + } else { + String code = extractErrorCode(message); + printFail("[S1] AddUser", "Ожидали status=200, получили status=" + status + ", code=" + code); + } + } + case 1 -> { + // AuthChallenge: статус 200 + authNonce + String nonce = extractAuthNonce(message); + GLOBAL_AUTH_NONCE = nonce; + if (status == 200 && nonce != null && !nonce.isBlank()) { + printOk("[S1] AuthChallenge", "status=200, получен authNonce=" + nonce); + } else { + String code = extractErrorCode(message); + printFail("[S1] AuthChallenge", + "Ожидали status=200 + непустой authNonce, получили status=" + + status + ", nonce=" + nonce + ", code=" + code); + } + } + case 2 -> { + // CreateAuthSession: статус 200 + sessionId & sessionPwd + String sid = extractSessionId(message); + String spwd = extractSessionPwd(message); + GLOBAL_SESSION_ID = sid; + GLOBAL_SESSION_PWD = spwd; + if (status == 200 && sid != null && !sid.isBlank() + && spwd != null && !spwd.isBlank()) { + printOk("[S1] CreateAuthSession", + "status=200, sessionId и sessionPwd получены"); + } else { + String code = extractErrorCode(message); + printFail("[S1] CreateAuthSession", + "Ожидали status=200 + непустые sessionId/sessionPwd, получили status=" + + status + ", sid=" + sid + ", code=" + code); + } + } + default -> { + // не должно сюда попадать + } } step++; @@ -219,7 +263,7 @@ public class Test_AddUser_and_Authorification { private static void runScenario_RefreshSession_WrongPwd() throws Exception { System.out.println(); System.out.println("=== СЦЕНАРИЙ 2: RefreshSession с НЕВЕРНЫМ sessionPwd ==="); - System.out.println("Ожидаем ОТРИЦАТЕЛЬНЫЙ ответ сервера (UNVERIFIED / SESSION_PWD_MISMATCH и т.п.)"); + System.out.println("Ожидаем ОТРИЦАТЕЛЬНЫЙ ответ сервера: status != 200, code = SESSION_PWD_MISMATCH"); if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) { System.out.println("⚠️ Нет sessionId или sessionPwd из сценария 1, пропускаем сценарий 2."); @@ -256,7 +300,18 @@ public class Test_AddUser_and_Authorification { System.out.println("📥 [S2] Ответ сервера (ожидаем ошибку):"); System.out.println(message); System.out.println("-----------------------------------------------------"); - System.out.println("💬 [S2] Если в ответе status != 200 и/или код ошибки про неверный пароль — это ПРАВИЛЬНОЕ поведение."); + + int status = extractStatus(message); + String code = extractErrorCode(message); + + if (status != 200 && "SESSION_PWD_MISMATCH".equals(code)) { + printOk("[S2] RefreshSession (wrong pwd)", + "Получена ожидаемая ошибка: status=" + status + ", code=" + code); + } else { + printFail("[S2] RefreshSession (wrong pwd)", + "Ожидали status!=200 + code=SESSION_PWD_MISMATCH, получили status=" + + status + ", code=" + code); + } webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario2 done"); webSocket.request(1); @@ -285,17 +340,17 @@ public class Test_AddUser_and_Authorification { } // ========================================================== - // SCENARIO 3: RefreshSession с правильными данными + // SCENARIO 3: RefreshSession OK + CloseActiveSession // ========================================================== - private static void runScenario_RefreshSession_CorrectPwd() throws Exception { + private static void runScenario_RefreshSession_CorrectPwd_And_Close() throws Exception { System.out.println(); - System.out.println("=== СЦЕНАРИЙ 3: RefreshSession с КОРРЕКТНЫМ sessionPwd ==="); - System.out.println("Ожидаем УСПЕШНЫЙ ответ сервера (status=200),"); - System.out.println(" а в payload должен вернуться актуальный storagePwd."); + System.out.println("=== СЦЕНАРИЙ 3: RefreshSession с КОРРЕКТНЫМ sessionPwd + CloseActiveSession ==="); + System.out.println("1) Ожидаем: status=200 и корректный storagePwd"); + System.out.println("2) Затем в этом же подключении вызываем CloseActiveSession для той же sessionId и ждём status=200."); - if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) { - System.out.println("⚠️ Нет sessionId или sessionPwd из сценария 1, пропускаем сценарий 3."); + if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null || GLOBAL_STORAGE_PWD_SENT == null) { + System.out.println("⚠️ Нет необходимых данных из сценария 1, пропускаем сценарий 3."); return; } @@ -305,6 +360,8 @@ public class Test_AddUser_and_Authorification { client.newWebSocketBuilder() .buildAsync(URI.create(WS_URI), new Listener() { + private int step = 0; // 0 - RefreshSession OK, 1 - CloseActiveSession + @Override public void onOpen(WebSocket webSocket) { System.out.println("✅ [S3] WebSocket подключен"); @@ -312,7 +369,7 @@ public class Test_AddUser_and_Authorification { String json = buildRefreshSessionJson(GLOBAL_SESSION_ID, GLOBAL_SESSION_PWD, "test-refresh-ok-1"); System.out.println(); - System.out.println("📤 [S3] Отправляем RefreshSession с КОРРЕКТНЫМ sessionPwd:"); + System.out.println("📤 [S3 / Шаг 1] Отправляем RefreshSession с КОРРЕКТНЫМ sessionPwd:"); System.out.println(json); webSocket.sendText(json, true); Listener.super.onOpen(webSocket); @@ -323,15 +380,52 @@ public class Test_AddUser_and_Authorification { CharSequence data, boolean last) { String message = data.toString(); - System.out.println("📥 [S3] Ответ сервера (ожидаем успех):"); + System.out.println("📥 [S3] Ответ сервера (step=" + step + "):"); System.out.println(message); System.out.println("-----------------------------------------------------"); - System.out.println("💬 [S3] Если status=200 — сессия успешно восстановлена."); - String storagePwdFromServer = extractStoragePwd(message); - System.out.println("🧾 [S3] storagePwd от сервера: " + storagePwdFromServer); - System.out.println(" (Должен совпадать с тем, что отправляли в шаге 3 сценария 1)"); - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario3 done"); + if (step == 0) { + // Ответ на RefreshSession + int status = extractStatus(message); + String storagePwdFromServer = extractStoragePwd(message); + + if (status == 200 && GLOBAL_STORAGE_PWD_SENT.equals(storagePwdFromServer)) { + printOk("[S3] RefreshSession (correct pwd)", + "status=200, storagePwd совпадает с отправленным ранее"); + } else { + String code = extractErrorCode(message); + printFail("[S3] RefreshSession (correct pwd)", + "Ожидали status=200 + storagePwd=" + + GLOBAL_STORAGE_PWD_SENT + + ", получили status=" + status + + ", storagePwd=" + storagePwdFromServer + + ", code=" + code); + } + + // Теперь отправляем CloseActiveSession для этой же sessionId + String closeJson = buildCloseActiveSessionJson(GLOBAL_SESSION_ID, "test-close-1"); + System.out.println(); + System.out.println("📤 [S3 / Шаг 2] Отправляем CloseActiveSession для sessionId=" + GLOBAL_SESSION_ID); + System.out.println(closeJson); + webSocket.sendText(closeJson, true); + step = 1; + } else if (step == 1) { + // Ответ на CloseActiveSession + int status = extractStatus(message); + String code = extractErrorCode(message); + + if (status == 200) { + printOk("[S3] CloseActiveSession", + "status=200, сессия закрыта (запись в БД удалена, другие подключения при наличии закрыты)"); + } else { + printFail("[S3] CloseActiveSession", + "Ожидали status=200, получили status=" + status + ", code=" + code); + } + + // Сервер может сам закрыть WebSocket, но мы тоже корректно закрываем + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario3 done"); + } + webSocket.request(1); return CompletableFuture.completedFuture(null); } @@ -357,6 +451,86 @@ public class Test_AddUser_and_Authorification { System.out.println("=== СЦЕНАРИЙ 3 завершён ==="); } + // ========================================================== + // SCENARIO 4: RefreshSession после закрытия сессии + // ========================================================== + + private static void runScenario_RefreshSession_AfterClose() throws Exception { + System.out.println(); + System.out.println("=== СЦЕНАРИЙ 4: RefreshSession после CloseActiveSession ==="); + System.out.println("Ожидаем: status != 200, code = SESSION_NOT_FOUND"); + + if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) { + System.out.println("⚠️ Нет sessionId или sessionPwd, пропускаем сценарий 4."); + return; + } + + CountDownLatch latch = new CountDownLatch(1); + HttpClient client = HttpClient.newHttpClient(); + + client.newWebSocketBuilder() + .buildAsync(URI.create(WS_URI), new Listener() { + + @Override + public void onOpen(WebSocket webSocket) { + System.out.println("✅ [S4] WebSocket подключен"); + webSocket.request(1); + + String json = buildRefreshSessionJson(GLOBAL_SESSION_ID, GLOBAL_SESSION_PWD, "test-refresh-after-close-1"); + System.out.println(); + System.out.println("📤 [S4] Отправляем RefreshSession ПОСЛЕ закрытия сессии:"); + System.out.println(json); + webSocket.sendText(json, true); + Listener.super.onOpen(webSocket); + } + + @Override + public CompletionStage onText(WebSocket webSocket, + CharSequence data, + boolean last) { + String message = data.toString(); + System.out.println("📥 [S4] Ответ сервера:"); + System.out.println(message); + System.out.println("-----------------------------------------------------"); + + int status = extractStatus(message); + String code = extractErrorCode(message); + + if (status != 200 && "SESSION_NOT_FOUND".equals(code)) { + printOk("[S4] RefreshSession after Close", + "Получена ожидаемая ошибка: status=" + status + ", code=" + code); + } else { + printFail("[S4] RefreshSession after Close", + "Ожидали status!=200 + code=SESSION_NOT_FOUND, получили status=" + + status + ", code=" + code); + } + + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario4 done"); + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + System.out.println("❌ [S4] Ошибка WebSocket-клиента: " + error.getMessage()); + error.printStackTrace(System.out); + latch.countDown(); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, + int statusCode, + String reason) { + System.out.println("🔚 [S4] Соединение закрыто. Код=" + statusCode + ", причина=" + reason); + latch.countDown(); + return CompletableFuture.completedFuture(null); + } + }).join(); + + latch.await(); + System.out.println("=== СЦЕНАРИЙ 4 завершён ==="); + } + // ========================================================== // JSON BUILDERS // ========================================================== @@ -462,6 +636,26 @@ public class Test_AddUser_and_Authorification { ); } + // 5) CloseActiveSession: можно передать sessionId, timeMs и signatureB64 + // В нашем случае уже есть авторизованная сессия, поэтому timeMs и signatureB64 + // можно задать нулями/пустыми — сервер их игнорирует в AUTH_STATUS_USER. + private static String buildCloseActiveSessionJson(String sessionId, String requestId) { + return """ + { + "op": "CloseActiveSession", + "requestId": "%s", + "payload": { + "sessionId": "%s", + "timeMs": 0, + "signatureB64": "" + } + } + """.formatted( + requestId, + sessionId + ); + } + // просто для теста: base64 от 32 байт "storage" ключа private static String generateFakeStoragePwd() { byte[] data = new byte[32]; @@ -526,4 +720,41 @@ public class Test_AddUser_and_Authorification { } return null; } + + private static int extractStatus(String json) { + try { + JsonNode root = JSON_MAPPER.readTree(json); + if (root.has("status")) { + return root.get("status").asInt(); + } + } catch (Exception e) { + System.out.println("⚠️ Не удалось распарсить status из ответа: " + e.getMessage()); + } + return -1; + } + + private static String extractErrorCode(String json) { + try { + JsonNode root = JSON_MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("code") && !payload.get("code").isNull()) { + return payload.get("code").asText(); + } + } catch (Exception e) { + System.out.println("⚠️ Не удалось распарсить code из ответа: " + e.getMessage()); + } + return null; + } + + // ========================================================== + // PRINT HELPERS + // ========================================================== + + private static void printOk(String testName, String details) { + System.out.println("✅ " + testName + " — " + details); + } + + private static void printFail(String testName, String details) { + System.out.println("❌ " + testName + " — " + details); + } } \ No newline at end of file