From e84c63c3d1c7bc12e7c4add06f0cf2d9b2a9274232db497e9f86c82e07101928 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 23 Jan 2026 20:50:58 +0300 Subject: [PATCH] 23 01 25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сделал авторификацию новую через sessionKey (Но пока тесты сессии падают) --- ... для создания ОПИСАНИЯ ПРООТОКОЛА блокчейна.txt | 2 + .../java/shine/db/DatabaseInitializer.java | 20 +- .../java/shine/db/dao/ActiveSessionsDAO.java | 24 +- .../shine/db/entities/ActiveSessionEntry.java | 17 +- .../ws_protocol/JSON/ConnectionContext.java | 83 ++++-- .../ws_protocol/JSON/JsonHandlerRegistry.java | 34 ++- .../auth/Net_AuthChallenge_Handler.java | 28 +- .../auth/Net_CloseActiveSession_Handler.java | 144 ++-------- .../auth/Net_CreateAuthSession__Handler.java | 158 ++++++---- .../auth/Net_ListSessions_Handler.java | 100 +------ .../auth/Net_RefreshSession_Handler.java | 230 ++------------- .../auth/Net_SessionChallenge_Handler.java | 87 ++++++ .../auth/Net_SessionLogin_Handler.java | 269 ++++++++++++++++++ .../Net_CloseActiveSession_Request.java | 45 +-- .../Net_CreateAuthSession_Request.java | 45 ++- .../Net_CreateAuthSession_Response.java | 20 +- .../entyties/Net_ListSessions_Request.java | 48 +--- .../entyties/Net_RefreshSession_Request.java | 84 +++--- .../entyties/Net_RefreshSession_Response.java | 56 ++-- .../Net_SessionChallenge_Request.java | 20 ++ .../Net_SessionChallenge_Response.java | 20 ++ .../entyties/Net_SessionLogin_Request.java | 54 ++++ .../entyties/Net_SessionLogin_Response.java | 20 ++ .../java/test/it/cases/IT_02_Sessions.java | 154 +++++----- .../java/test/it/runner/IT_RunAllMain.java | 2 - .../java/test/it/utils/json/JsonBuilders.java | 82 ++++-- .../java/test/it/utils/json/JsonParsers.java | 13 + 27 files changed, 1010 insertions(+), 849 deletions(-) create mode 100644 shine-server-blockchain/src/main/промт для создания ОПИСАНИЯ ПРООТОКОЛА блокчейна.txt create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionChallenge_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Response.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java diff --git a/shine-server-blockchain/src/main/промт для создания ОПИСАНИЯ ПРООТОКОЛА блокчейна.txt b/shine-server-blockchain/src/main/промт для создания ОПИСАНИЯ ПРООТОКОЛА блокчейна.txt new file mode 100644 index 0000000..69006b6 --- /dev/null +++ b/shine-server-blockchain/src/main/промт для создания ОПИСАНИЯ ПРООТОКОЛА блокчейна.txt @@ -0,0 +1,2 @@ +НАПИШИ ВНАЧАЛЕ ФОРМАТ ОБЩЕГО ЗАГЛАВИЯ. +А ПОТОМ ФОРМАТ ПО КАЖДОМУ ТИПУ (И В НЁМ СУБТИПУ БЛОКОВ) ДЛЯ ЧЕГО НАДО, ЧТО ХРАНИТЬСЯ, КАКИЕ ПРАВИЛА И ОСОБЕННОСТИ ЗАПОЛНЕНИЯ diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 00b6b4f..626734c 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -18,9 +18,9 @@ import java.sql.Statement; * - создаём ТОЛЬКО таблицы/индексы * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st) * - * Зачем так: - * - триггеры часто ломают совместимость с внешними SQLite-просмотрщиками/сборками - * - проще поддерживать/мигрировать + * v2 (sessions): + * - active_sessions.session_pwd удалён + * - active_sessions.session_key хранит публичный ключ сессии (sessionPubKeyB64) */ public final class DatabaseInitializer { @@ -28,25 +28,16 @@ public final class DatabaseInitializer { /* ===================== TEXT (msg_type=1) ===================== */ - /** Новое сообщение (начало ветки). */ public static final short TEXT_NEW = 1; - - /** Ответ на сообщение (reply). */ public static final short TEXT_REPLY = 2; - - /** Репост (repost). */ public static final short TEXT_REPOST = 3; - - /** Редактирование (edit). ВАЖНО: серверное значение = 10. */ public static final short TEXT_EDIT = 10; /* ===================== REACTION (msg_type=2) ===================== */ - /** Лайк (LIKE). */ public static final short REACTION_LIKE = 1; /* ===================== CONNECTION (msg_type=3) ===================== */ - // FRIEND=10/11, CONTACT=20/21, FOLLOW=30/31 public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_UNFRIEND = 11; @@ -123,12 +114,12 @@ public final class DatabaseInitializer { ON solana_users (login); """); - // 2. active_sessions + // 2. active_sessions (v2) st.executeUpdate(""" CREATE TABLE IF NOT EXISTS active_sessions ( session_id TEXT NOT NULL PRIMARY KEY, login TEXT NOT NULL, - session_pwd TEXT NOT NULL, + session_key TEXT NOT NULL, storage_pwd TEXT NOT NULL, session_created_at_ms INTEGER NOT NULL, last_authirificated_at_ms INTEGER NOT NULL, @@ -325,7 +316,6 @@ public final class DatabaseInitializer { ON message_stats (to_login); """); - // ВАЖНО: триггеры ставим отдельно DatabaseTriggersInstaller.createAllTriggers(st); } } 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 46c8029..9af6428 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 @@ -32,13 +32,12 @@ public final class ActiveSessionsDAO { // -------------------- INSERT -------------------- - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ public void insert(Connection c, ActiveSessionEntry session) throws SQLException { String sql = """ INSERT INTO active_sessions ( session_id, login, - session_pwd, + session_key, storage_pwd, session_created_at_ms, last_authirificated_at_ms, @@ -55,7 +54,7 @@ public final class ActiveSessionsDAO { try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, session.getSessionId()); ps.setString(2, session.getLogin()); - ps.setString(3, session.getSessionPwd()); + ps.setString(3, session.getSessionKey()); ps.setString(4, session.getStoragePwd()); ps.setLong(5, session.getSessionCreatedAtMs()); ps.setLong(6, session.getLastAuthirificatedAtMs()); @@ -70,7 +69,6 @@ public final class ActiveSessionsDAO { } } - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ public void insert(ActiveSessionEntry session) throws SQLException { try (Connection c = db.getConnection()) { insert(c, session); @@ -79,13 +77,12 @@ public final class ActiveSessionsDAO { // -------------------- SELECT -------------------- - /** Получить по sessionId с внешним соединением. Соединение НЕ закрывает. */ public ActiveSessionEntry getBySessionId(Connection c, String sessionId) throws SQLException { String sql = """ SELECT session_id, login, - session_pwd, + session_key, storage_pwd, session_created_at_ms, last_authirificated_at_ms, @@ -109,20 +106,18 @@ public final class ActiveSessionsDAO { } } - /** Получить по sessionId без внешнего соединения. Сам открывает/закрывает. */ public ActiveSessionEntry getBySessionId(String sessionId) throws SQLException { try (Connection c = db.getConnection()) { return getBySessionId(c, sessionId); } } - /** Получить список по login с внешним соединением. Соединение НЕ закрывает. */ public List getByLogin(Connection c, String login) throws SQLException { String sql = """ SELECT session_id, login, - session_pwd, + session_key, storage_pwd, session_created_at_ms, last_authirificated_at_ms, @@ -149,7 +144,6 @@ public final class ActiveSessionsDAO { return result; } - /** Получить список по login без внешнего соединения. Сам открывает/закрывает. */ public List getByLogin(String login) throws SQLException { try (Connection c = db.getConnection()) { return getByLogin(c, login); @@ -158,7 +152,6 @@ public final class ActiveSessionsDAO { // -------------------- UPDATE -------------------- - /** Обновить lastAuthirificatedAtMs с внешним соединением. Соединение НЕ закрывает. */ public void updateLastAuthirificatedAtMs(Connection c, String sessionId, long lastAuthMs) throws SQLException { String sql = """ UPDATE active_sessions @@ -173,14 +166,12 @@ public final class ActiveSessionsDAO { } } - /** Обновить lastAuthirificatedAtMs без внешнего соединения. Сам открывает/закрывает. */ public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException { try (Connection c = db.getConnection()) { updateLastAuthirificatedAtMs(c, sessionId, lastAuthMs); } } - /** Обновить данные refresh с внешним соединением. Соединение НЕ закрывает. */ public void updateOnRefresh( Connection c, String sessionId, @@ -213,7 +204,6 @@ public final class ActiveSessionsDAO { } } - /** Обновить данные refresh без внешнего соединения. Сам открывает/закрывает. */ public void updateOnRefresh( String sessionId, long lastAuthMs, @@ -229,7 +219,6 @@ public final class ActiveSessionsDAO { // -------------------- DELETE -------------------- - /** Удалить по sessionId с внешним соединением. Соединение НЕ закрывает. */ public void deleteBySessionId(Connection c, String sessionId) throws SQLException { String sql = "DELETE FROM active_sessions WHERE session_id = ?"; @@ -239,7 +228,6 @@ public final class ActiveSessionsDAO { } } - /** Удалить по sessionId без внешнего соединения. Сам открывает/закрывает. */ public void deleteBySessionId(String sessionId) throws SQLException { try (Connection c = db.getConnection()) { deleteBySessionId(c, sessionId); @@ -251,7 +239,7 @@ public final class ActiveSessionsDAO { private ActiveSessionEntry mapRow(ResultSet rs) throws SQLException { String sessionId = rs.getString("session_id"); String login = rs.getString("login"); - String sessionPwd = rs.getString("session_pwd"); + String sessionKey = rs.getString("session_key"); String storagePwd = rs.getString("storage_pwd"); long sessionCreatedAtMs = rs.getLong("session_created_at_ms"); long lastAuthirificatedAtMs = rs.getLong("last_authirificated_at_ms"); @@ -266,7 +254,7 @@ public final class ActiveSessionsDAO { return new ActiveSessionEntry( sessionId, login, - sessionPwd, + sessionKey, storagePwd, sessionCreatedAtMs, lastAuthirificatedAtMs, diff --git a/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java b/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java index 30714a7..4e55416 100644 --- a/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java +++ b/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java @@ -7,10 +7,14 @@ public class ActiveSessionEntry { private String sessionId; private String login; - private String sessionPwd; + + /** session_key: публичный ключ сессии (base64 от 32 байт). */ + private String sessionKey; + private String storagePwd; private long sessionCreatedAtMs; private long lastAuthirificatedAtMs; + private String pushEndpoint; private String pushP256dhKey; private String pushAuthKey; @@ -20,12 +24,11 @@ public class ActiveSessionEntry { private String clientInfoFromRequest; private String userLanguage; - public ActiveSessionEntry() { - } + public ActiveSessionEntry() { } public ActiveSessionEntry(String sessionId, String login, - String sessionPwd, + String sessionKey, String storagePwd, long sessionCreatedAtMs, long lastAuthirificatedAtMs, @@ -38,7 +41,7 @@ public class ActiveSessionEntry { String userLanguage) { this.sessionId = sessionId; this.login = login; - this.sessionPwd = sessionPwd; + this.sessionKey = sessionKey; this.storagePwd = storagePwd; this.sessionCreatedAtMs = sessionCreatedAtMs; this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; @@ -57,8 +60,8 @@ public class ActiveSessionEntry { public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } - public String getSessionPwd() { return sessionPwd; } - public void setSessionPwd(String sessionPwd) { this.sessionPwd = sessionPwd; } + public String getSessionKey() { return sessionKey; } + public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; } public String getStoragePwd() { return storagePwd; } public void setStoragePwd(String storagePwd) { this.storagePwd = storagePwd; } 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 535632e..518e9c8 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 @@ -7,12 +7,22 @@ import shine.db.entities.ActiveSessionEntry; /** * ConnectionContext — контекст состояния одного WebSocket-соединения. * Живёт ровно столько же, сколько живёт подключение. + * + * Важно (v2): + * - Авторизация всегда 2 шага: + * A) Создание новой сессии через deviceKey: + * AuthChallenge(login) -> ctx.authNonce + * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession + * + * B) Вход в существующую сессию через sessionKey: + * SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt + * SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER */ public class ConnectionContext { // Статусы аутентификации public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован - public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // получен AuthChallenge + public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь // Полный пользователь из БД (solana_users) @@ -23,20 +33,38 @@ public class ConnectionContext { /** * Идентификатор сессии — base64-строка от 32 байт. + * Заполняется после успешного входа (AUTH_STATUS_USER). */ private String sessionId; - /** - * Секрет сессии (то, что хранится в active_sessions.session_pwd). - */ - private String sessionPwd; - /** * Одноразовый nonce, выданный на шаге 1 (AuthChallenge), - * используется на шаге 2 для проверки подписи. + * используется на шаге CreateAuthSession для проверки подписи deviceKey. */ private String authNonce; + /* ===================== SessionLogin challenge (v2) ===================== */ + + /** + * Одноразовый nonce, выданный на шаге SessionChallenge(sessionId), + * используется на шаге SessionLogin для проверки подписи sessionKey. + */ + private String sessionLoginNonce; + + /** + * sessionId, для которого был выдан sessionLoginNonce. + * Нужен, чтобы SessionLogin не мог "подставить" другой sessionId. + */ + private String sessionLoginSessionId; + + /** + * Время истечения sessionLoginNonce (мс с 1970-01-01). + * Если текущее время > expiresAt, то nonce считается недействительным. + */ + private long sessionLoginNonceExpiresAtMs; + + /* ====================================================================== */ + /** * Текущий статус аутентификации. * См. константы AUTH_STATUS_* @@ -83,7 +111,7 @@ public class ConnectionContext { return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; } - // --- sessionId / sessionPwd --- + // --- sessionId --- public String getSessionId() { return sessionId; @@ -93,14 +121,6 @@ public class ConnectionContext { this.sessionId = sessionId; } - public String getSessionPwd() { - return sessionPwd; - } - - public void setSessionPwd(String sessionPwd) { - this.sessionPwd = sessionPwd; - } - // --- authNonce --- public String getAuthNonce() { @@ -111,6 +131,32 @@ public class ConnectionContext { this.authNonce = authNonce; } + // --- sessionLoginNonce (v2) --- + + public String getSessionLoginNonce() { + return sessionLoginNonce; + } + + public void setSessionLoginNonce(String sessionLoginNonce) { + this.sessionLoginNonce = sessionLoginNonce; + } + + public String getSessionLoginSessionId() { + return sessionLoginSessionId; + } + + public void setSessionLoginSessionId(String sessionLoginSessionId) { + this.sessionLoginSessionId = sessionLoginSessionId; + } + + public long getSessionLoginNonceExpiresAtMs() { + return sessionLoginNonceExpiresAtMs; + } + + public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) { + this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs; + } + // --- auth status --- public int getAuthenticationStatus() { @@ -134,9 +180,12 @@ public class ConnectionContext { activeSessionEntry = null; sessionId = null; - sessionPwd = null; authNonce = null; + sessionLoginNonce = null; + sessionLoginSessionId = null; + sessionLoginNonceExpiresAtMs = 0; + authenticationStatus = AUTH_STATUS_NONE; wsSession = null; } 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 2a06cc8..10af81a 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 @@ -2,20 +2,32 @@ package server.logic.ws_protocol.JSON; import server.logic.ws_protocol.JSON.entyties.Net_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_CloseActiveSession_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_RefreshSession_Handler; + +// --- NEW v2 session login --- +import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler; + +// --- auth entities --- 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_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RefreshSession_Request; + +// --- NEW v2 entities --- +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.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; + import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; + import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler; import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler; import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler; @@ -37,12 +49,19 @@ public final class JsonHandlerRegistry { // Map.of(...) поддерживает максимум 10 пар => используем Map.ofEntries(...) private static final Map HANDLERS = Map.ofEntries( - Map.entry("RefreshSession", new Net_RefreshSession_Handler()), Map.entry("AddUser", new Net_AddUser_Handler()), + + // --- auth --- Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()), Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()), Map.entry("ListSessions", new Net_ListSessions_Handler()), + + // --- login to existing session in 2 steps --- + Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), + Map.entry("SessionLogin", new Net_SessionLogin_Handler()), + + // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), // --- userParams --- @@ -55,12 +74,19 @@ public final class JsonHandlerRegistry { ); private static final Map> REQUEST_TYPES = Map.ofEntries( - Map.entry("RefreshSession", Net_RefreshSession_Request.class), Map.entry("AddUser", Net_AddUser_Request.class), + + // --- auth --- Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class), Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class), Map.entry("ListSessions", Net_ListSessions_Request.class), + + // --- NEW v2 --- + Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), + Map.entry("SessionLogin", Net_SessionLogin_Request.class), + + // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), // --- userParams --- diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java index 01b022b..fb6cbe7 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java @@ -1,9 +1,11 @@ package server.logic.ws_protocol.JSON.handlers.auth; import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.entyties.*; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.*; +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_AuthChallenge_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.SolanaUsersDAO; @@ -13,10 +15,18 @@ import java.security.SecureRandom; import java.util.Base64; /** - * Шаг 1 авторизации: запрос выдачи временного nonce (authNonce). + * AuthChallenge (v2) — шаг 1 создания новой сессии. * - * Клиент по логину просит сервер сгенерировать случайный authNonce, - * который будет использован на втором шаге при подписи. + * Логика авторизации (v2): + * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. + * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: + * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) + * + * Что делает: + * 1) Проверяет login. + * 2) Находит пользователя (solana_users). + * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. + * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. */ public class Net_AuthChallenge_Handler implements JsonMessageHandler { @@ -47,9 +57,7 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler { ); } - // 2) Ищем пользователя в локальной БД SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { return NetExceptionResponseFactory.error( req, @@ -59,21 +67,15 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler { ); } - // 3) Заполняем контекст пользователем ctx.setSolanaUser(solanaUserEntry); - - // 3.1) Отмечаем, что по этому соединению начата авторификация ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); - // 4) Генерируем одноразовый authNonce = base64(32 случайных байт) byte[] buf = new byte[32]; RANDOM.nextBytes(buf); String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - // Сохраняем challenge в отдельном поле authNonce ctx.setAuthNonce(authNonce); - // 5) Формируем ответ Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); 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 index 1a2e95c..f3218d1 100644 --- 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 @@ -4,10 +4,11 @@ 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.handlers.auth.entyties.*; 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_CloseActiveSession_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; @@ -18,31 +19,15 @@ import shine.db.entities.SolanaUserEntry; import java.sql.SQLException; /** - * Хэндлер CloseActiveSession. + * CloseActiveSession (v2) — закрытие текущей или другой сессии. * - * Назначение: - * - закрыть одну из активных сессий пользователя: - * * либо явно указанную в sessionId, - * * либо текущую (если sessionId не задана). - * - * Допустимые состояния: - * - AUTH_STATUS_USER: - * * timeMs / signatureB64 могут быть пустыми. - * * Достаточно факта текущей авторизации. - * - * - AUTH_STATUS_AUTH_IN_PROGRESS: - * * требуется проверка подписи Ed25519 над строкой - * "AUTHORIFICATED:" + timeMs + authNonce - * (authNonce взят на шаге AuthChallenge и хранится в ctx.authNonce). - * * Если подпись корректна, можно закрывать сессию даже до полноценной - * установки новой сессии. + * Логика авторизации (v2): + * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). + * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет. * * Закрытие: - * - запись ActiveSession удаляется из БД; - * - если по этой sessionId есть активное WebSocket-подключение: - * * если это ДРУГОЕ подключение — оно закрывается сразу; - * * если это ТЕКУЩЕЕ подключение — сначала отправляется ответ 200, - * а закрытие выполняется в отдельном потоке с небольшой задержкой. + * - удаляем запись из БД + * - если по sessionId есть активный WS — закрываем его */ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { @@ -52,100 +37,24 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { 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) { + if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { return NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", - "Операция доступна только в состоянии авторизации или авторификации" + "Операция доступна только для авторизованных пользователей" ); } SolanaUserEntry user = ctx.getSolanaUser(); String currentLogin = user.getLogin(); - 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) { + if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) { targetSessionId = ctx.getSessionId(); + } else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { + targetSessionId = ctx.getActiveSession().getSessionId(); } else { return NetExceptionResponseFactory.error( req, @@ -156,10 +65,9 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { } } - ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance(); ActiveSessionEntry targetSession; try { - targetSession = sessionsDao.getBySessionId(targetSessionId); + targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId); } catch (SQLException e) { log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); return NetExceptionResponseFactory.error( @@ -190,50 +98,31 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { 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); + ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId); } catch (SQLException e) { log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); - // Логируем, но считаем, что для клиента сессия всё равно должна быть недействительна. } ConnectionContext ctxToClose = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); - if (ctxToClose == null) { - return; - } + if (ctxToClose == null) return; if (isCurrentSession && ctxToClose == currentCtx) { - // Это текущее подключение: закрываем после отправки ответа. new Thread(() -> { try { Thread.sleep(50); } catch (InterruptedException ignored) {} WsConnectionUtils.closeConnection( @@ -243,7 +132,6 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { ); }, "CloseSession-" + targetSessionId).start(); } else { - // Другая сессия — можно закрыть сразу WsConnectionUtils.closeConnection( ctxToClose, 4000, 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 ac53047..3e9f6ff 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 @@ -1,14 +1,15 @@ package server.logic.ws_protocol.JSON.handlers.auth; +import org.eclipse.jetty.websocket.api.Session; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.eclipse.jetty.websocket.api.Session; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.*; 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_CreateAuthSession_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; @@ -20,37 +21,37 @@ import shine.geo.GeoLookupService; import utils.crypto.Ed25519Util; import java.nio.charset.StandardCharsets; -import java.sql.SQLException; import java.security.SecureRandom; +import java.sql.SQLException; import java.util.Base64; +/** + * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). + * + * Логика авторизации (v2): + * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) + * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, + * отправляет на сервер ТОЛЬКО sessionPubKeyB64. + * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. + * + * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): + * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} + * + * На выходе: + * - создаётся запись active_sessions + * - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия") + * - ответ: sessionId + */ 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(); public static final long ALLOWED_SKEW_MS = 30_000L; - public static boolean verifyAuthorificatedSignature( - SolanaUserEntry 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 { + Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; if (ctx == null @@ -93,6 +94,43 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } + String sessionPubKeyB64 = req.getSessionPubKeyB64(); + if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_SESSION_PUBKEY", + "Пустой sessionPubKeyB64" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); + return err; + } + + // Проверим, что ключ декодируется в 32 байта + byte[] sessionPubKey32; + try { + sessionPubKey32 = decodeBase64Any(sessionPubKeyB64); + } catch (IllegalArgumentException e) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", + "Некорректный base64 в sessionPubKeyB64" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); + return err; + } + if (sessionPubKey32.length != 32) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_SESSION_PUBKEY_LEN", + "sessionPubKey должен быть 32 байта" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); + return err; + } + String signatureB64 = req.getSignatureB64(); if (signatureB64 == null || signatureB64.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( @@ -107,7 +145,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { long timeMs = req.getTimeMs(); long nowMs = System.currentTimeMillis(); - long diff = Math.abs(nowMs - timeMs); if (diff > ALLOWED_SKEW_MS) { Net_Response err = NetExceptionResponseFactory.error( @@ -125,15 +162,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { clientInfoFromClient = clientInfoFromClient.substring(0, 50); } - String pubKeyB64 = user.getDeviceKey(); - if (pubKeyB64 == null || pubKeyB64.isBlank()) { + String devicePubKeyB64 = user.getDeviceKey(); + if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, - "NO_PUBKEY1", - "Отсутствует публичный ключ pubkey1 для пользователя" + "NO_DEVICE_KEY", + "Отсутствует deviceKey у пользователя" ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no pubkey"); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey"); return err; } @@ -141,7 +178,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { boolean sigOk; try { - sigOk = verifyAuthorificatedSignature(user, authNonce, timeMs, signatureB64); + sigOk = verifyCreateSessionSignature( + user, + login, + authNonce, + timeMs, + sessionPubKeyB64, + storagePwd, + signatureB64 + ); } catch (IllegalArgumentException ex) { Net_Response err = NetExceptionResponseFactory.error( req, @@ -164,9 +209,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - // --- Генерируем настоящий секрет сессии (sessionPwd) и sessionId --- - String newSessionPwd = generateRandomSecret(); - String sessionId = generateRandomSessionId(); + // --- генерируем sessionId --- + String sessionId = generateRandom32B64Url(); long now = System.currentTimeMillis(); // --- Сбор данных о клиенте (IP, UA, язык) --- @@ -174,11 +218,12 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); - String clientIp = null; + String clientIp = ""; if (wsSession != null) { - clientIp = ClientInfoService.extractClientIp(wsSession); + String ip = ClientInfoService.extractClientIp(wsSession); + if (ip != null) clientIp = ip; - if (clientIp != null && !clientIp.isBlank()) { + if (!clientIp.isBlank()) { try { GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); } catch (Exception e) { @@ -186,7 +231,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { } } } - if (clientIp == null) clientIp = ""; // --- создаём запись ActiveSession и сохраняем в БД --- ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); @@ -196,7 +240,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { activeSessionEntry = new ActiveSessionEntry( sessionId, login, - newSessionPwd, + sessionPubKeyB64, // session_key (pubkey) storagePwd, now, now, @@ -225,8 +269,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { // --- обновляем контекст --- ctx.setActiveSession(activeSessionEntry); ctx.setSessionId(sessionId); - ctx.setSessionPwd(newSessionPwd); // теперь в контексте хранится секрет сессии - ctx.setAuthNonce(null); // одноразовый nonce больше не нужен + ctx.setAuthNonce(null); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); @@ -237,25 +280,40 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { resp.setRequestId(req.getRequestId()); resp.setStatus(WireCodes.Status.OK); resp.setSessionId(sessionId); - resp.setSessionPwd(newSessionPwd); return resp; } - /** - * Генерация случайного sessionId: base64-строка от 32 байт. - */ - private String generateRandomSessionId() { + private static boolean verifyCreateSessionSignature( + SolanaUserEntry user, + String login, + String authNonce, + long timeMs, + String sessionPubKeyB64, + String storagePwd, + String signatureB64 + ) throws IllegalArgumentException { + + byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); + byte[] signature64 = decodeBase64Any(signatureB64); + + String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce + ":" + sessionPubKeyB64 + ":" + storagePwd; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } + + private static String generateRandom32B64Url() { byte[] buf = new byte[32]; RANDOM.nextBytes(buf); return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); } - /** - * Генерация случайного секрета (sessionPwd): base64-строка от 32 байт. - */ - private String generateRandomSecret() { - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + private static byte[] decodeBase64Any(String s) throws IllegalArgumentException { + // сначала url-safe, потом обычный + try { + return Base64.getUrlDecoder().decode(s); + } catch (IllegalArgumentException ignore) { + return Base64.getDecoder().decode(s); + } } } \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index 8a4d759..9569d2b 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -3,12 +3,13 @@ package server.logic.ws_protocol.JSON.handlers.auth; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.*; 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.JSON.handlers.auth.entyties.Net_ListSessions_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; import shine.db.entities.ActiveSessionEntry; @@ -20,16 +21,11 @@ import java.util.ArrayList; import java.util.List; /** - * Хэндлер ListSessions. + * ListSessions (v2) — список активных сессий. * - * Назначение: - * - вернуть список всех активных сессий текущего пользователя - * (по loginId из ctx/solanaUser). - * - * Безопасность: - * - анонимный клиент → NOT_AUTHENTICATED (UNVERIFIED); - * - AUTH_STATUS_USER → достаточно факта авторизации; - * - AUTH_STATUS_AUTH_IN_PROGRESS → требуется подпись, как в CreateAuthSession/CloseActiveSession. + * Логика авторизации (v2): + * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). + * - Никаких подписей здесь больше нет. */ public class Net_ListSessions_Handler implements JsonMessageHandler { @@ -39,8 +35,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - // 1) Проверяем, что вообще есть пользователь в контексте - if (ctx == null || ctx.getSolanaUser() == null) { + if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { return NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, @@ -52,81 +47,6 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { SolanaUserEntry user = ctx.getSolanaUser(); String currentLogin = user.getLogin(); - 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", - "Операция ListSessions недоступна в текущем статусе аутентификации" - ); - } - - // 2) Если мы ещё на шаге 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", - "Подпись не прошла проверку" - ); - } - } - - // 3) Тянем все активные сессии пользователя из БД List sessions; try { sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); @@ -140,10 +60,9 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { ); } - // 4) Собираем DTO с геолокацией List resultList = new ArrayList<>(); for (ActiveSessionEntry s : sessions) { - SessionInfo info = new Net_ListSessions_Response.SessionInfo(); + SessionInfo info = new SessionInfo(); info.setSessionId(s.getSessionId()); info.setClientInfoFromClient(s.getClientInfoFromClient()); info.setClientInfoFromRequest(s.getClientInfoFromRequest()); @@ -156,7 +75,6 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { resultList.add(info); } - // 5) Формируем ответ Net_ListSessions_Response resp = new Net_ListSessions_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RefreshSession_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RefreshSession_Handler.java index 9bfc70c..2b4c8ab 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RefreshSession_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RefreshSession_Handler.java @@ -1,200 +1,30 @@ -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.handlers.auth.entyties.*; -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 shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; - -import java.sql.SQLException; - -/** - * Хэндлер RefreshSession. - * - * При успешной проверке sessionId + sessionPwd: - * - подтягивает пользователя по loginId из сессии; - * - заполняет ConnectionContext; - * - обновляет lastAuthirificatedAtMs и метаданные сессии в БД; - * - возвращает storagePwd в payload. - */ -public class Net_RefreshSession_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_RefreshSession_Handler.class); - - // максимум 50 символов для clientInfo от клиента - private static final int CLIENT_INFO_MAX_LEN = 50; - - @Override - public Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception { - Net_RefreshSession_Request req = (Net_RefreshSession_Request) request; - - String sessionId = req.getSessionId(); - String sessionPwd = req.getSessionPwd(); - String clientInfoFromClient = trimClientInfo(req.getClientInfo()); - - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_ID", - "Пустой идентификатор сессии" - ); - } - - if (sessionPwd == null || sessionPwd.isEmpty()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PWD", - "Пустой пароль сессии" - ); - } - - ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance(); - ActiveSessionEntry session; - try { - session = sessionsDao.getBySessionId(sessionId); - } catch (SQLException e) { - log.error("Ошибка БД при поиске сессии sessionId={}", sessionId, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - String dbPwd = session.getSessionPwd(); - if (dbPwd == null || !dbPwd.equals(sessionPwd)) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_PWD_MISMATCH", - "Неверный пароль сессии" - ); - } - - // --- вытаскиваем пользователя по login из сессии --- - SolanaUserEntry solanaUserEntry; - String login = session.getLogin(); - - try { - SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - solanaUserEntry = usersDao.getByLogin(login); - } catch (SQLException e) { - log.error("Ошибка БД при поиске пользователя по login={} из сессии", login, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_USER_LOOKUP", - "Ошибка доступа к базе данных при получении пользователя для сессии" - ); - } - - if (solanaUserEntry == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "USER_NOT_FOUND_FOR_SESSION", - "Пользователь для данной сессии не найден" - ); - } - - // --- собираем данные о клиенте из WebSocket-запроса --- - String clientIp = null; - String clientInfoFromRequest = null; - String userLanguage = null; - - if (ctx != null && ctx.getWsSession() != null) { - // Нормальное получение IP-адреса клиента - clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); - - // Сделать запрос геолокации и никуда её не сохранять: - // вызов с кэшированием в БД, нужно только для прогрева кэша. - if (clientIp != null && !clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - // Геолокация не критична, просто логируем на debug/trace при желании - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - - clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); - userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); - } - - long nowMs = System.currentTimeMillis(); - - // --- обновляем запись в БД (lastAuth + мета) --- - try { - sessionsDao.updateOnRefresh( - sessionId, - nowMs, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } catch (SQLException e) { - log.error("Ошибка БД при обновлении метаданных сессии sessionId={}", sessionId, e); - // не роняем авторизацию, но логируем - } - - // Также обновим объект session в памяти (если дальше кто-то его использует) - session.setLastAuthirificatedAtMs(nowMs); - session.setClientIp(clientIp); - session.setClientInfoFromClient(clientInfoFromClient); - session.setClientInfoFromRequest(clientInfoFromRequest); - session.setUserLanguage(userLanguage); - - // --- обновляем контекст соединения --- - if (ctx != null) { - ctx.setActiveSession(session); - ctx.setSolanaUser(solanaUserEntry); - ctx.setSessionId(sessionId); - ctx.setSessionPwd(sessionPwd); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - // Регистрируем это подключение в глобальном реестре активных соединений - ActiveConnectionsRegistry.getInstance().register(ctx); - } - - // --- ответ OK + storagePwd --- - Net_RefreshSession_Response resp = new Net_RefreshSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setStoragePwd(session.getStoragePwd()); - return resp; - } - - private String trimClientInfo(String info) { - if (info == null) return null; - info = info.trim(); - if (info.length() > CLIENT_INFO_MAX_LEN) { - return info.substring(0, CLIENT_INFO_MAX_LEN); - } - return info; - } -} \ No newline at end of file +//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_RefreshSession_Request; +//import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +//import server.logic.ws_protocol.WireCodes; +// +///** +// * RefreshSession (v2) — ОТКЛЮЧЕН. +// * +// * Раньше это был "короткий вход" (1 запрос) по sessionId+sessionPwd. +// * Теперь вход всегда 2 шага: SessionChallenge -> SessionLogin (подпись sessionKey). +// */ +//public class Net_RefreshSession_Handler implements JsonMessageHandler { +// +// @Override +// public Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception { +// Net_RefreshSession_Request req = (Net_RefreshSession_Request) request; +// +// return NetExceptionResponseFactory.error( +// req, +// WireCodes.Status.GONE, // 410 +// "DISABLED_V2", +// "RefreshSession отключён в v2. Используй SessionChallenge + SessionLogin." +// ); +// } +//} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionChallenge_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionChallenge_Handler.java new file mode 100644 index 0000000..6a4e7a6 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionChallenge_Handler.java @@ -0,0 +1,87 @@ +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_SessionChallenge_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.ActiveSessionsDAO; +import shine.db.entities.ActiveSessionEntry; + +import java.security.SecureRandom; +import java.sql.SQLException; +import java.util.Base64; + +/** + * SessionChallenge (v2) — шаг 1 входа в существующую сессию. + * + * Логика авторизации (v2): + * - Вход в существующую сессию ВСЕГДА в 2 шага: + * 1) SessionChallenge(sessionId) -> nonce + * 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) + * + * Что делает: + * - Проверяет, что sessionId существует в БД. + * - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx: + * ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs. + */ +public class Net_SessionChallenge_Handler implements JsonMessageHandler { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final long NONCE_TTL_MS = 60_000L; + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq; + + String sessionId = req.getSessionId(); + if (sessionId == null || sessionId.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_SESSION_ID", + "Пустой sessionId" + ); + } + + ActiveSessionEntry session; + try { + session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); + } catch (SQLException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "DB_ERROR", + "Ошибка доступа к базе данных" + ); + } + + if (session == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "SESSION_NOT_FOUND", + "Сессия не найдена" + ); + } + + byte[] buf = new byte[32]; + RANDOM.nextBytes(buf); + String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + + long now = System.currentTimeMillis(); + ctx.setSessionLoginNonce(nonce); + ctx.setSessionLoginSessionId(sessionId); + ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS); + + Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setNonce(nonce); + return resp; + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java new file mode 100644 index 0000000..b2b0a8c --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -0,0 +1,269 @@ +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.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_SessionLogin_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.ActiveSessionsDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.ActiveSessionEntry; +import shine.db.entities.SolanaUserEntry; +import shine.geo.ClientInfoService; +import shine.geo.GeoLookupService; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.Base64; + +/** + * SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey). + * + * Логика авторизации (v2): + * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). + * - SessionLogin проверяет подпись sessionKey над строкой: + * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} + * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). + * + * При успехе: + * - ctx становится AUTH_STATUS_USER + * - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang) + * - возвращаем storagePwd + */ +public class Net_SessionLogin_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); + + private static final long ALLOWED_SKEW_MS = 30_000L; + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; + + String sessionId = req.getSessionId(); + if (sessionId == null || sessionId.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_SESSION_ID", + "Пустой sessionId" + ); + } + + // проверка челленджа + if (ctx.getSessionLoginNonce() == null + || ctx.getSessionLoginSessionId() == null + || System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) { + + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "NO_CHALLENGE", + "Нет активного SessionChallenge или nonce истёк" + ); + } + + if (!sessionId.equals(ctx.getSessionLoginSessionId())) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "SESSION_ID_MISMATCH", + "nonce был выдан для другого sessionId" + ); + } + + long timeMs = req.getTimeMs(); + long nowMs = System.currentTimeMillis(); + if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "TIME_SKEW", + "Время клиента отличается от сервера более чем на 30 секунд" + ); + } + + String signatureB64 = req.getSignatureB64(); + if (signatureB64 == null || signatureB64.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_SIGNATURE", + "Пустая подпись" + ); + } + + ActiveSessionEntry session; + try { + session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); + } catch (SQLException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "DB_ERROR", + "Ошибка доступа к базе данных" + ); + } + + if (session == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "SESSION_NOT_FOUND", + "Сессия не найдена" + ); + } + + String sessionPubKeyB64 = session.getSessionKey(); // это pubKey + if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "NO_SESSION_KEY", + "В сессии не задан session_key" + ); + } + + String nonce = ctx.getSessionLoginNonce(); + + boolean sigOk; + try { + sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, 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", + "Подпись не прошла проверку" + ); + } + + // сжигаем nonce + ctx.setSessionLoginNonce(null); + ctx.setSessionLoginSessionId(null); + ctx.setSessionLoginNonceExpiresAtMs(0); + + // подтягиваем пользователя + SolanaUserEntry user; + try { + user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); + } catch (SQLException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "DB_ERROR_USER_LOOKUP", + "Ошибка доступа к базе данных при получении пользователя" + ); + } + + if (user == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "USER_NOT_FOUND_FOR_SESSION", + "Пользователь для данной сессии не найден" + ); + } + + // обновление метаданных + String clientInfoFromClient = req.getClientInfo(); + if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { + clientInfoFromClient = clientInfoFromClient.substring(0, 50); + } + + String clientIp = null; + String clientInfoFromRequest = null; + String userLanguage = null; + + if (ctx.getWsSession() != null) { + clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); + clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); + userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); + + if (clientIp != null && !clientIp.isBlank()) { + try { + GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); + } catch (Exception e) { + log.debug("Geo lookup failed for ip={}", clientIp, e); + } + } + } + + long now = System.currentTimeMillis(); + try { + ActiveSessionsDAO.getInstance().updateOnRefresh( + sessionId, + now, + clientIp, + clientInfoFromClient, + clientInfoFromRequest, + userLanguage + ); + } catch (SQLException e) { + log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e); + } + + session.setLastAuthirificatedAtMs(now); + session.setClientIp(clientIp); + session.setClientInfoFromClient(clientInfoFromClient); + session.setClientInfoFromRequest(clientInfoFromRequest); + session.setUserLanguage(userLanguage); + + // ctx + ctx.setActiveSession(session); + ctx.setSolanaUser(user); + ctx.setSessionId(sessionId); + ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); + + ActiveConnectionsRegistry.getInstance().register(ctx); + + // ответ + Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setStoragePwd(session.getStoragePwd()); + return resp; + } + + private static boolean verifySessionLoginSignature( + String sessionPubKeyB64, + String sessionId, + long timeMs, + String nonce, + String signatureB64 + ) throws IllegalArgumentException { + + byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); + byte[] signature64 = decodeBase64Any(signatureB64); + + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } + + private static byte[] decodeBase64Any(String s) throws IllegalArgumentException { + try { + return Base64.getUrlDecoder().decode(s); + } catch (IllegalArgumentException ignore) { + return Base64.getDecoder().decode(s); + } + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CloseActiveSession_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CloseActiveSession_Request.java index a9b98ca..8629fa3 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CloseActiveSession_Request.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CloseActiveSession_Request.java @@ -5,35 +5,20 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; /** * Запрос CloseActiveSession — закрытие активной сессии пользователя. * - * Допустимые режимы: + * Новая логика (v2): + * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). + * - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет. * - * 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-подключение. + * payload: + * { + * "sessionId": "..." // опционально; если пусто — закрываем текущую + * } */ 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; } @@ -41,20 +26,4 @@ public class Net_CloseActiveSession_Request extends Net_Request { 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/handlers/auth/entyties/Net_CreateAuthSession_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java index 427f40b..d851e2e 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java @@ -3,40 +3,31 @@ package server.logic.ws_protocol.JSON.handlers.auth.entyties; import server.logic.ws_protocol.JSON.entyties.Net_Request; /** - * Шаг 2 авторизации: подтверждение владения ключом и установка сессии. + * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. * - * Клиент: - * 1) получает от сервера authNonce на шаге 1; - * 2) генерирует свой StoragePwd (base64 от 32 байт); - * 3) формирует строку для подписи: - * "AUTHORIFICATED:" + timeMs + authNonce - * 4) подписывает эту строку своим приватным ключом (pubkey1), - * отправляет подпись и StoragePwd на сервер. + * Шаги: + * 1) AuthChallenge(login) -> authNonce + * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) * - * Дополнительно: - * - clientInfo — короткая строка (до 50 символов) с данными об устройстве/клиенте. + * Подпись deviceKey делается над строкой (UTF-8): + * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} * - * Формат входящего JSON: - * { - * "op": "CreateAuthSession", - * "requestId": "...", - * "payload": { - * "storagePwd": "base64-строка-от-32-байт", - * "timeMs": 1733310000000, - * "signatureB64": "base64-подпись-Ed25519", - * "clientInfo": "Chrome/Android" // опционально, до 50 символов - * } - * } + * Важно: + * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). + * - В БД active_sessions.session_key хранится sessionPubKeyB64. */ public class Net_CreateAuthSession_Request extends Net_Request { - /** Клиентский пароль для хранения данных (base64 от 32 байт). */ + /** Клиентский пароль для хранения данных (base64url от 32 байт). */ private String storagePwd; + /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ + private String sessionPubKeyB64; + /** Время на стороне клиента (мс с 1970-01-01). */ private long timeMs; - /** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */ + /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ private String signatureB64; /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ @@ -50,6 +41,14 @@ public class Net_CreateAuthSession_Request extends Net_Request { this.storagePwd = storagePwd; } + public String getSessionPubKeyB64() { + return sessionPubKeyB64; + } + + public void setSessionPubKeyB64(String sessionPubKeyB64) { + this.sessionPubKeyB64 = sessionPubKeyB64; + } + public long getTimeMs() { return timeMs; } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java index 80e1a06..6df3ec2 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java @@ -3,10 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.auth.entyties; import server.logic.ws_protocol.JSON.entyties.Net_Response; /** - * Ответ на CreateAuthSession. + * Ответ на CreateAuthSession (v2). * * При успехе сервер создаёт запись в active_sessions - * и возвращает идентификатор сессии sessionId и секрет сессии sessionPwd. + * и возвращает идентификатор сессии sessionId. * * JSON: * { @@ -14,19 +14,15 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; * "requestId": "...", * "status": 200, * "payload": { - * "sessionId": "base64-строка-от-32-байт", - * "sessionPwd": "base64-строка-от-32-байт" + * "sessionId": "base64url(32)" * } * } */ public class Net_CreateAuthSession_Response extends Net_Response { - /** Идентификатор сессии, base64 от 32 байт. */ + /** Идентификатор сессии, base64url от 32 байт. */ private String sessionId; - /** Секрет сессии, base64 от 32 байт. */ - private String sessionPwd; - public String getSessionId() { return sessionId; } @@ -34,12 +30,4 @@ public class Net_CreateAuthSession_Response extends Net_Response { public void setSessionId(String sessionId) { this.sessionId = sessionId; } - - public String getSessionPwd() { - return sessionPwd; - } - - public void setSessionPwd(String sessionPwd) { - this.sessionPwd = sessionPwd; - } } \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Request.java index 66598e1..2a8e378 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Request.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Request.java @@ -5,50 +5,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; /** * Запрос ListSessions — список активных сессий пользователя. * - * Режимы безопасности такие же, как у CloseActiveSession: - * - * 1) Пользователь уже авторизован (AUTH_STATUS_USER): - * - поля timeMs и signatureB64 могут быть пустыми и игнорируются. - * - * 2) Пользователь в статусе AUTH_STATUS_AUTH_IN_PROGRESS: - * - требуется подпись Ed25519 над строкой - * "AUTHORIFICATED:" + timeMs + authNonce - * (authNonce сохранён в ctx.authNonce после AuthChallenge). - * - * 3) Анонимный клиент (AUTH_STATUS_NONE или нет пользователя в ctx): - * - возвращается ошибка NOT_AUTHENTICATED. - * - * JSON: - * { - * "op": "ListSessions", - * "requestId": "...", - * "payload": { - * "timeMs": 1733310000000, // при AUTH_IN_PROGRESS - * "signatureB64": "base64-подпись" // при AUTH_IN_PROGRESS - * } - * } + * Новая логика (v2): + * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). + * - Пустой payload. */ public class Net_ListSessions_Request extends Net_Request { - - /** Время на стороне клиента (мс с 1970-01-01). Используется при AUTH_IN_PROGRESS. */ - private long timeMs; - - /** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */ - private String signatureB64; - - 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/handlers/auth/entyties/Net_RefreshSession_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Request.java index 70cddc6..bab1f1d 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Request.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Request.java @@ -1,48 +1,36 @@ -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос RefreshSession. - * - * Используется для повторного входа без повторной подписи: - * клиент хранит sessionId и sessionPwd, которые получил на шаге 2. - * - * JSON (payload): - * { - * "sessionId": "base64-id-сессии", - * "sessionPwd": "base64-sessionPwd", - * "clientInfo": "до 50 символов, краткая строка об устройстве" - * } - */ -public class Net_RefreshSession_Request extends Net_Request { - - private String sessionId; - private String sessionPwd; - - /** - * Краткая строка с информацией об устройстве/клиенте, до 50 символов. - * Например: "PWA/Chrome/Android". - */ - private String clientInfo; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public String getSessionPwd() { - return sessionPwd; - } - - public void setSessionPwd(String sessionPwd) { - this.sessionPwd = sessionPwd; - } - - public String getClientInfo() { return clientInfo; } - - public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } -} \ No newline at end of file +//package server.logic.ws_protocol.JSON.handlers.auth.entyties; +// +//import server.logic.ws_protocol.JSON.entyties.Net_Request; +// +///** +// * Запрос RefreshSession. +// * +// * В новой версии (v2) RefreshSession ОТКЛЮЧЕН. +// * Оставлен временно для совместимости, handler вернёт 410 GONE. +// */ +//public class Net_RefreshSession_Request extends Net_Request { +// +// private String sessionId; +// private String sessionPwd; +// private String clientInfo; +// +// public String getSessionId() { +// return sessionId; +// } +// +// public void setSessionId(String sessionId) { +// this.sessionId = sessionId; +// } +// +// public String getSessionPwd() { +// return sessionPwd; +// } +// +// public void setSessionPwd(String sessionPwd) { +// this.sessionPwd = sessionPwd; +// } +// +// public String getClientInfo() { return clientInfo; } +// +// public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } +//} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Response.java index f4a3fd5..ae63df6 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RefreshSession_Response.java @@ -1,33 +1,23 @@ -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на RefreshSession. - * - * Дополнительно к статусу 200 сервер возвращает storagePwd, - * чтобы клиент мог восстановить/синхронизировать локальное хранилище. - * - * JSON: - * { - * "op": "RefreshSession", - * "requestId": "...", - * "status": 200, - * "payload": { - * "storagePwd": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_RefreshSession_Response extends Net_Response { - - /** Пароль хранилища, сохранённый в сессии (storagePwd). */ - private String storagePwd; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } -} +//package server.logic.ws_protocol.JSON.handlers.auth.entyties; +// +//import server.logic.ws_protocol.JSON.entyties.Net_Response; +// +///** +// * Ответ на RefreshSession. +// * +// * В новой версии (v2) RefreshSession ОТКЛЮЧЕН. +// * Этот класс можно оставить временно для совместимости сериализации, +// * но handler будет возвращать 410 GONE. +// */ +//public class Net_RefreshSession_Response extends Net_Response { +// +// private String storagePwd; +// +// public String getStoragePwd() { +// return storagePwd; +// } +// +// public void setStoragePwd(String storagePwd) { +// this.storagePwd = storagePwd; +// } +//} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Request.java new file mode 100644 index 0000000..e911e71 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Request.java @@ -0,0 +1,20 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Шаг 1 входа в существующую сессию (v2): + * SessionChallenge(sessionId) -> nonce + */ +public class Net_SessionChallenge_Request extends Net_Request { + + private String sessionId; + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Response.java new file mode 100644 index 0000000..fc4c80d --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionChallenge_Response.java @@ -0,0 +1,20 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** + * Ответ на SessionChallenge (v2). + * payload: { "nonce": "base64url(32)" } + */ +public class Net_SessionChallenge_Response extends Net_Response { + + private String nonce; + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java new file mode 100644 index 0000000..2b98f80 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java @@ -0,0 +1,54 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Шаг 2 входа в существующую сессию (v2): + * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER + * + * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): + * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} + * + * nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL). + */ +public class Net_SessionLogin_Request extends Net_Request { + + private String sessionId; + private long timeMs; + private String signatureB64; + + /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ + private String clientInfo; + + 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; + } + + public String getClientInfo() { + return clientInfo; + } + + public void setClientInfo(String clientInfo) { + this.clientInfo = clientInfo; + } +} \ No newline at end of file diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java new file mode 100644 index 0000000..2e046a3 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java @@ -0,0 +1,20 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** + * Ответ на SessionLogin (v2). + * payload: { "storagePwd": "base64url(32)" } + */ +public class Net_SessionLogin_Response extends Net_Response { + + private String storagePwd; + + public String getStoragePwd() { + return storagePwd; + } + + public void setStoragePwd(String storagePwd) { + this.storagePwd = storagePwd; + } +} \ No newline at end of file diff --git a/src/test/java/test/it/cases/IT_02_Sessions.java b/src/test/java/test/it/cases/IT_02_Sessions.java index 9db4bd4..26d6279 100644 --- a/src/test/java/test/it/cases/IT_02_Sessions.java +++ b/src/test/java/test/it/cases/IT_02_Sessions.java @@ -13,11 +13,16 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; /** - * IT_02_Sessions + * IT_02_Sessions (v2) * * Цель: - * - проверить создание/листинг/refresh/close + * - проверить создание/листинг/вход-в-сессию(2 шага)/close * - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) + * + * Протокол v2: + * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey) + * - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey)) + * - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin) */ public class IT_02_Sessions { @@ -31,110 +36,65 @@ public class IT_02_Sessions { } public static String run() { - TestResult r = new TestResult("IT_02_Sessions"); + TestResult r = new TestResult("IT_02_Sessions(v2)"); Duration t = Duration.ofSeconds(5); - String s1Id, s1Pwd; - String s2Id, s2Pwd; - String s3Id, s3Pwd; + Session s1, s2, s3; try { - // 1) Создаём 3 сессии (каждая — отдельным соединением, чтобы не зависеть от состояния WS) - Session s1 = createSession(LOGIN, t, r, "S1"); - s1Id = s1.sessionId; s1Pwd = s1.sessionPwd; + // 1) Создаём 3 сессии (каждая — отдельным соединением) + s1 = createSession(LOGIN, t, r, "S1"); + s2 = createSession(LOGIN, t, r, "S2"); + s3 = createSession(LOGIN, t, r, "S3"); - Session s2 = createSession(LOGIN, t, r, "S2"); - s2Id = s2.sessionId; s2Pwd = s2.sessionPwd; - - Session s3 = createSession(LOGIN, t, r, "S3"); - s3Id = s3.sessionId; s3Pwd = s3.sessionPwd; - - // 2) ListSessions в AUTH_IN_PROGRESS — должны быть S1,S2,S3 + // 2) Входим в S1 (2 шага) и делаем ListSessions (AUTH_STATUS_USER) — должны быть S1,S2,S3 try (WsSession ws = WsSession.open()) { - String nonceResp = ws.call("AuthChallenge(list)", JsonBuilders.authChallenge(LOGIN), t); - assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(list) must be 200"); - String nonce = JsonParsers.authNonce(nonceResp); - assertNotNull(nonce, "authNonce must not be null"); + sessionLogin2Steps(ws, s1, t, "Login(S1)", r); - long timeMs = System.currentTimeMillis(); - String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN)); - - String listResp = ws.call("ListSessions(AUTH_IN_PROGRESS)", JsonBuilders.listSessions(timeMs, sig), t); - assertEquals(200, JsonParsers.status(listResp), "ListSessions must be 200"); + String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t); + assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200"); List ids = JsonParsers.sessionIds(listResp); - r.ok("ListSessions(AUTH_IN_PROGRESS): " + ids); + r.ok("ListSessions(AUTH_STATUS_USER): " + ids); - assertTrue(ids.contains(s1Id), "Must contain S1"); - assertTrue(ids.contains(s2Id), "Must contain S2"); - assertTrue(ids.contains(s3Id), "Must contain S3"); + assertTrue(ids.contains(s1.sessionId), "Must contain S1"); + assertTrue(ids.contains(s2.sessionId), "Must contain S2"); + assertTrue(ids.contains(s3.sessionId), "Must contain S3"); r.ok("Проверка OK: список содержит S1,S2,S3"); } - // 3) RefreshSession(S1) -> после refresh в этом же соединении делаем ListSessions(AUTH_STATUS_USER) (timeMs=0) - try (WsSession ws = WsSession.open()) { - String refreshResp = ws.call("RefreshSession(S1)", JsonBuilders.refreshSession(s1Id, s1Pwd), t); - assertEquals(200, JsonParsers.status(refreshResp), "RefreshSession(S1) must be 200"); - assertNotNull(JsonParsers.storagePwd(refreshResp), "storagePwd must not be null"); - r.ok("RefreshSession(S1): OK"); - - String listInUserResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t); - assertEquals(200, JsonParsers.status(listInUserResp), "ListSessions(AUTH_STATUS_USER) must be 200"); - - List ids = JsonParsers.sessionIds(listInUserResp); - r.ok("ListSessions(AUTH_STATUS_USER): " + ids); - - assertTrue(ids.contains(s1Id)); - assertTrue(ids.contains(s2Id)); - assertTrue(ids.contains(s3Id)); - r.ok("Проверка OK: AUTH_STATUS_USER список содержит S1,S2,S3"); - } - - // 4) Проверяем CloseActiveSession, но так, чтобы итогом всё равно осталось 3 сессии: - // создаём TEMP, закрываем TEMP, убеждаемся что S1,S2,S3 остались. + // 3) Проверяем CloseActiveSession так, чтобы итогом всё равно осталось 3 сессии: + // создаём TEMP, логинимся в S1, закрываем TEMP, убеждаемся что S1,S2,S3 остались. Session temp = createSession(LOGIN, t, r, "TEMP"); - String tempId = temp.sessionId; try (WsSession ws = WsSession.open()) { - String nonceResp = ws.call("AuthChallenge(close TEMP)", JsonBuilders.authChallenge(LOGIN), t); - assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(close TEMP) must be 200"); - String nonce = JsonParsers.authNonce(nonceResp); - assertNotNull(nonce); + sessionLogin2Steps(ws, s1, t, "Login(S1) for close", r); - long timeMs = System.currentTimeMillis(); - String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN)); - - String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(tempId, timeMs, sig), t); + String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(temp.sessionId, 0L, ""), t); assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200"); r.ok("CloseActiveSession(TEMP): OK"); } - // 5) Финальная проверка: снова ListSessions(AUTH_IN_PROGRESS) => S1,S2,S3 должны остаться, TEMP нет + // 4) Финальная проверка: снова логинимся в S1 и ListSessions => S1,S2,S3 должны остаться, TEMP нет try (WsSession ws = WsSession.open()) { - String nonceResp = ws.call("AuthChallenge(final list)", JsonBuilders.authChallenge(LOGIN), t); - assertEquals(200, JsonParsers.status(nonceResp)); - String nonce = JsonParsers.authNonce(nonceResp); - assertNotNull(nonce); + sessionLogin2Steps(ws, s1, t, "Final Login(S1)", r); - long timeMs = System.currentTimeMillis(); - String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN)); - - String listResp = ws.call("ListSessions(final AUTH_IN_PROGRESS)", JsonBuilders.listSessions(timeMs, sig), t); + String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t); assertEquals(200, JsonParsers.status(listResp)); List ids = JsonParsers.sessionIds(listResp); r.ok("Final ListSessions: " + ids); - assertTrue(ids.contains(s1Id)); - assertTrue(ids.contains(s2Id)); - assertTrue(ids.contains(s3Id)); - assertFalse(ids.contains(tempId)); + assertTrue(ids.contains(s1.sessionId)); + assertTrue(ids.contains(s2.sessionId)); + assertTrue(ids.contains(s3.sessionId)); + assertFalse(ids.contains(temp.sessionId)); r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)"); } } catch (Throwable e) { - r.fail("IT_02_Sessions упал: " + e.getMessage()); + r.fail("IT_02_Sessions(v2) упал: " + e.getMessage()); } return r.summaryLine(); @@ -142,24 +102,56 @@ public class IT_02_Sessions { private static Session createSession(String login, Duration t, TestResult r, String label) { try (WsSession ws = WsSession.open()) { + + // шаг 1: AuthChallenge String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t); assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200"); - String nonce = JsonParsers.authNonce(nonceResp); - assertNotNull(nonce, "authNonce must not be null for " + label); + String authNonce = JsonParsers.authNonce(nonceResp); + assertNotNull(authNonce, "authNonce must not be null for " + label); - String createResp = ws.call("CreateAuthSession(" + label + ")", JsonBuilders.createAuthSession(login, nonce, TestConfig.fakeStoragePwd()), t); + // для тестов: sessionKey = deviceKey (в реале будет отдельный keypair) + String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login); + + // storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его) + String storagePwd = TestConfig.fakeStoragePwd(); + + // шаг 2: CreateAuthSession (device подпись + sessionPubKey) + String createResp = ws.call( + "CreateAuthSession(" + label + ")", + JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64), + t + ); assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200"); String sid = JsonParsers.sessionId(createResp); - String spw = JsonParsers.sessionPwd(createResp); - assertNotNull(sid, "sessionId must not be null"); - assertNotNull(spw, "sessionPwd must not be null"); r.ok("Создана сессия " + label + ": sessionId=" + sid); - return new Session(sid, spw); + + // для тестов используем devicePriv как sessionPriv + byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(login); + + return new Session(sid, sessionPrivKey, storagePwd); } } - private record Session(String sessionId, String sessionPwd) {} + private static void sessionLogin2Steps(WsSession ws, Session s, Duration t, String label, TestResult r) { + // шаг 1: SessionChallenge(sessionId) + String chResp = ws.call("SessionChallenge " + label, JsonBuilders.sessionChallenge(s.sessionId), t); + assertEquals(200, JsonParsers.status(chResp), "SessionChallenge must be 200"); + String nonce = JsonParsers.sessionNonce(chResp); + assertNotNull(nonce, "SessionChallenge nonce must not be null"); + + // шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) + String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, nonce, s.sessionPrivKey), t); + assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200"); + + String storagePwd = JsonParsers.storagePwd(loginResp); + assertNotNull(storagePwd, "storagePwd must not be null after SessionLogin"); + assertEquals(s.storagePwd, storagePwd, "storagePwd must match what client provided on CreateAuthSession"); + + r.ok(label + ": SessionLogin OK, storagePwd verified"); + } + + private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {} } \ No newline at end of file diff --git a/src/test/java/test/it/runner/IT_RunAllMain.java b/src/test/java/test/it/runner/IT_RunAllMain.java index 8f770a6..5e81b9c 100644 --- a/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/src/test/java/test/it/runner/IT_RunAllMain.java @@ -4,7 +4,6 @@ import test.it.cases.IT_01_AddUser; import test.it.cases.IT_02_Sessions; import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth; -import test.it.cases.IT_05_ListSubscribedChannels_200; import test.it.utils.log.TestLog; import java.util.ArrayList; @@ -31,7 +30,6 @@ public class IT_RunAllMain { String s2 = IT_02_Sessions.run(); summaries.add(s2); if (s2.contains("FAIL:")) failed++; String s3 = IT_03_AddBlock_NoAuth.run(); summaries.add(s3); if (s3.contains("FAIL:")) failed++; String s4 = IT_04_UserParams_NoAuth.run(); summaries.add(s4); if (s4.contains("FAIL:")) failed++; - String s5 = IT_05_ListSubscribedChannels_200.run(); summaries.add(s5); if (s5.contains("FAIL:")) failed++; TestLog.title("IT RUN RESULT (per test)"); for (String s : summaries) System.out.println(s); diff --git a/src/test/java/test/it/utils/json/JsonBuilders.java b/src/test/java/test/it/utils/json/JsonBuilders.java index 0e3f19d..0ec5e31 100644 --- a/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/src/test/java/test/it/utils/json/JsonBuilders.java @@ -58,9 +58,11 @@ public final class JsonBuilders { """.formatted(requestId, login); } - // ---------------- CreateAuthSession ---------------- + // ---------------- CreateAuthSession (v2) ---------------- + // v2: sessionKey генерируется на клиенте, на сервер отправляем только sessionPubKey (base64). + // подпись шага CreateAuthSession всё ещё делается deviceKey: "AUTHORIFICATED:" + timeMs + authNonce - public static String createAuthSession(String login, String authNonce, String storagePwd) { + public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionPubKeyB64) { long timeMs = System.currentTimeMillis(); byte[] devicePriv = TestConfig.getDevicePrivatKey(login); String sigB64 = signAuthorificated(authNonce, timeMs, devicePriv); @@ -72,12 +74,56 @@ public final class JsonBuilders { "requestId": "%s", "payload": { "storagePwd": "%s", + "sessionPubKeyB64": "%s", "timeMs": %d, "signatureB64": "%s", "clientInfo": "%s" } } - """.formatted(requestId, storagePwd, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); + """.formatted( + requestId, + storagePwd, + sessionPubKeyB64, + timeMs, + sigB64, + TestConfig.TEST_CLIENT_INFO + ); + } + + // ---------------- SessionChallenge (v2) ---------------- + + public static String sessionChallenge(String sessionId) { + String requestId = TestIds.next("sch"); + return """ + { + "op": "SessionChallenge", + "requestId": "%s", + "payload": { + "sessionId": "%s" + } + } + """.formatted(requestId, sessionId); + } + + // ---------------- SessionLogin (v2) ---------------- + + public static String sessionLogin(String sessionId, String nonce, byte[] sessionPrivKey) { + long timeMs = System.currentTimeMillis(); + String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); + + String requestId = TestIds.next("slogin"); + return """ + { + "op": "SessionLogin", + "requestId": "%s", + "payload": { + "sessionId": "%s", + "timeMs": %d, + "signatureB64": "%s", + "clientInfo": "%s" + } + } + """.formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); } // ---------------- ListSessions ---------------- @@ -97,23 +143,6 @@ public final class JsonBuilders { """.formatted(requestId, timeMs, signatureB64); } - // ---------------- RefreshSession ---------------- - - public static String refreshSession(String sessionId, String sessionPwd) { - String requestId = TestIds.next("refresh"); - return """ - { - "op": "RefreshSession", - "requestId": "%s", - "payload": { - "sessionId": "%s", - "sessionPwd": "%s", - "clientInfo": "%s" - } - } - """.formatted(requestId, sessionId, sessionPwd, TestConfig.TEST_CLIENT_INFO); - } - // ---------------- CloseActiveSession ---------------- public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) { @@ -145,7 +174,6 @@ public final class JsonBuilders { """.formatted(requestId, login); } - /** * Подпись для режима AUTH_IN_PROGRESS: * preimage = "AUTHORIFICATED:" + timeMs + authNonce @@ -157,4 +185,16 @@ public final class JsonBuilders { byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); return Base64.getEncoder().encodeToString(sig); } + + /** + * Подпись для SessionLogin(v2): + * preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce + * подписываем sessionPrivKey. + */ + public static String signSessionLogin(String sessionId, long timeMs, String nonce, byte[] sessionPrivKey) { + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + byte[] sig = Ed25519Util.sign(preimage, sessionPrivKey); + return Base64.getEncoder().encodeToString(sig); + } } \ No newline at end of file diff --git a/src/test/java/test/it/utils/json/JsonParsers.java b/src/test/java/test/it/utils/json/JsonParsers.java index bb05819..107e4e7 100644 --- a/src/test/java/test/it/utils/json/JsonParsers.java +++ b/src/test/java/test/it/utils/json/JsonParsers.java @@ -30,6 +30,18 @@ public final class JsonParsers { } } + /** nonce из SessionChallenge(v2) */ + public static String sessionNonce(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("nonce")) return payload.get("nonce").asText(); + return null; + } catch (Exception e) { + return null; + } + } + public static String sessionId(String json) { try { JsonNode root = MAPPER.readTree(json); @@ -41,6 +53,7 @@ public final class JsonParsers { } } + // оставляю для совместимости с другими тестами, но в IT_02(v2) больше не используется public static String sessionPwd(String json) { try { JsonNode root = MAPPER.readTree(json);