Сделал авторификацию новую через sessionKey

(Но пока тесты сессии падают)
This commit is contained in:
AidarKC 2026-01-23 20:50:58 +03:00
parent 580695b486
commit e84c63c3d1
27 changed files with 1010 additions and 849 deletions

View File

@ -0,0 +1,2 @@
НАПИШИ ВНАЧАЛЕ ФОРМАТ ОБЩЕГО ЗАГЛАВИЯ.
А ПОТОМ ФОРМАТ ПО КАЖДОМУ ТИПУ (И В НЁМ СУБТИПУ БЛОКОВ) ДЛЯ ЧЕГО НАДО, ЧТО ХРАНИТЬСЯ, КАКИЕ ПРАВИЛА И ОСОБЕННОСТИ ЗАПОЛНЕНИЯ

View File

@ -18,9 +18,9 @@ import java.sql.Statement;
* - создаём ТОЛЬКО таблицы/индексы * - создаём ТОЛЬКО таблицы/индексы
* - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st) * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st)
* *
* Зачем так: * v2 (sessions):
* - триггеры часто ломают совместимость с внешними SQLite-просмотрщиками/сборками * - active_sessions.session_pwd удалён
* - проще поддерживать/мигрировать * - active_sessions.session_key хранит публичный ключ сессии (sessionPubKeyB64)
*/ */
public final class DatabaseInitializer { public final class DatabaseInitializer {
@ -28,25 +28,16 @@ public final class DatabaseInitializer {
/* ===================== TEXT (msg_type=1) ===================== */ /* ===================== TEXT (msg_type=1) ===================== */
/** Новое сообщение (начало ветки). */
public static final short TEXT_NEW = 1; public static final short TEXT_NEW = 1;
/** Ответ на сообщение (reply). */
public static final short TEXT_REPLY = 2; public static final short TEXT_REPLY = 2;
/** Репост (repost). */
public static final short TEXT_REPOST = 3; public static final short TEXT_REPOST = 3;
/** Редактирование (edit). ВАЖНО: серверное значение = 10. */
public static final short TEXT_EDIT = 10; public static final short TEXT_EDIT = 10;
/* ===================== REACTION (msg_type=2) ===================== */ /* ===================== REACTION (msg_type=2) ===================== */
/** Лайк (LIKE). */
public static final short REACTION_LIKE = 1; public static final short REACTION_LIKE = 1;
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== 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_FRIEND = 10;
public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_UNFRIEND = 11;
@ -123,12 +114,12 @@ public final class DatabaseInitializer {
ON solana_users (login); ON solana_users (login);
"""); """);
// 2. active_sessions // 2. active_sessions (v2)
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS active_sessions ( CREATE TABLE IF NOT EXISTS active_sessions (
session_id TEXT NOT NULL PRIMARY KEY, session_id TEXT NOT NULL PRIMARY KEY,
login TEXT NOT NULL, login TEXT NOT NULL,
session_pwd TEXT NOT NULL, session_key TEXT NOT NULL,
storage_pwd TEXT NOT NULL, storage_pwd TEXT NOT NULL,
session_created_at_ms INTEGER NOT NULL, session_created_at_ms INTEGER NOT NULL,
last_authirificated_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); ON message_stats (to_login);
"""); """);
// ВАЖНО: триггеры ставим отдельно
DatabaseTriggersInstaller.createAllTriggers(st); DatabaseTriggersInstaller.createAllTriggers(st);
} }
} }

View File

@ -32,13 +32,12 @@ public final class ActiveSessionsDAO {
// -------------------- INSERT -------------------- // -------------------- INSERT --------------------
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
public void insert(Connection c, ActiveSessionEntry session) throws SQLException { public void insert(Connection c, ActiveSessionEntry session) throws SQLException {
String sql = """ String sql = """
INSERT INTO active_sessions ( INSERT INTO active_sessions (
session_id, session_id,
login, login,
session_pwd, session_key,
storage_pwd, storage_pwd,
session_created_at_ms, session_created_at_ms,
last_authirificated_at_ms, last_authirificated_at_ms,
@ -55,7 +54,7 @@ public final class ActiveSessionsDAO {
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, session.getSessionId()); ps.setString(1, session.getSessionId());
ps.setString(2, session.getLogin()); ps.setString(2, session.getLogin());
ps.setString(3, session.getSessionPwd()); ps.setString(3, session.getSessionKey());
ps.setString(4, session.getStoragePwd()); ps.setString(4, session.getStoragePwd());
ps.setLong(5, session.getSessionCreatedAtMs()); ps.setLong(5, session.getSessionCreatedAtMs());
ps.setLong(6, session.getLastAuthirificatedAtMs()); ps.setLong(6, session.getLastAuthirificatedAtMs());
@ -70,7 +69,6 @@ public final class ActiveSessionsDAO {
} }
} }
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
public void insert(ActiveSessionEntry session) throws SQLException { public void insert(ActiveSessionEntry session) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
insert(c, session); insert(c, session);
@ -79,13 +77,12 @@ public final class ActiveSessionsDAO {
// -------------------- SELECT -------------------- // -------------------- SELECT --------------------
/** Получить по sessionId с внешним соединением. Соединение НЕ закрывает. */
public ActiveSessionEntry getBySessionId(Connection c, String sessionId) throws SQLException { public ActiveSessionEntry getBySessionId(Connection c, String sessionId) throws SQLException {
String sql = """ String sql = """
SELECT SELECT
session_id, session_id,
login, login,
session_pwd, session_key,
storage_pwd, storage_pwd,
session_created_at_ms, session_created_at_ms,
last_authirificated_at_ms, last_authirificated_at_ms,
@ -109,20 +106,18 @@ public final class ActiveSessionsDAO {
} }
} }
/** Получить по sessionId без внешнего соединения. Сам открывает/закрывает. */
public ActiveSessionEntry getBySessionId(String sessionId) throws SQLException { public ActiveSessionEntry getBySessionId(String sessionId) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
return getBySessionId(c, sessionId); return getBySessionId(c, sessionId);
} }
} }
/** Получить список по login с внешним соединением. Соединение НЕ закрывает. */
public List<ActiveSessionEntry> getByLogin(Connection c, String login) throws SQLException { public List<ActiveSessionEntry> getByLogin(Connection c, String login) throws SQLException {
String sql = """ String sql = """
SELECT SELECT
session_id, session_id,
login, login,
session_pwd, session_key,
storage_pwd, storage_pwd,
session_created_at_ms, session_created_at_ms,
last_authirificated_at_ms, last_authirificated_at_ms,
@ -149,7 +144,6 @@ public final class ActiveSessionsDAO {
return result; return result;
} }
/** Получить список по login без внешнего соединения. Сам открывает/закрывает. */
public List<ActiveSessionEntry> getByLogin(String login) throws SQLException { public List<ActiveSessionEntry> getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
return getByLogin(c, login); return getByLogin(c, login);
@ -158,7 +152,6 @@ public final class ActiveSessionsDAO {
// -------------------- UPDATE -------------------- // -------------------- UPDATE --------------------
/** Обновить lastAuthirificatedAtMs с внешним соединением. Соединение НЕ закрывает. */
public void updateLastAuthirificatedAtMs(Connection c, String sessionId, long lastAuthMs) throws SQLException { public void updateLastAuthirificatedAtMs(Connection c, String sessionId, long lastAuthMs) throws SQLException {
String sql = """ String sql = """
UPDATE active_sessions UPDATE active_sessions
@ -173,14 +166,12 @@ public final class ActiveSessionsDAO {
} }
} }
/** Обновить lastAuthirificatedAtMs без внешнего соединения. Сам открывает/закрывает. */
public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException { public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
updateLastAuthirificatedAtMs(c, sessionId, lastAuthMs); updateLastAuthirificatedAtMs(c, sessionId, lastAuthMs);
} }
} }
/** Обновить данные refresh с внешним соединением. Соединение НЕ закрывает. */
public void updateOnRefresh( public void updateOnRefresh(
Connection c, Connection c,
String sessionId, String sessionId,
@ -213,7 +204,6 @@ public final class ActiveSessionsDAO {
} }
} }
/** Обновить данные refresh без внешнего соединения. Сам открывает/закрывает. */
public void updateOnRefresh( public void updateOnRefresh(
String sessionId, String sessionId,
long lastAuthMs, long lastAuthMs,
@ -229,7 +219,6 @@ public final class ActiveSessionsDAO {
// -------------------- DELETE -------------------- // -------------------- DELETE --------------------
/** Удалить по sessionId с внешним соединением. Соединение НЕ закрывает. */
public void deleteBySessionId(Connection c, String sessionId) throws SQLException { public void deleteBySessionId(Connection c, String sessionId) throws SQLException {
String sql = "DELETE FROM active_sessions WHERE session_id = ?"; 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 { public void deleteBySessionId(String sessionId) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
deleteBySessionId(c, sessionId); deleteBySessionId(c, sessionId);
@ -251,7 +239,7 @@ public final class ActiveSessionsDAO {
private ActiveSessionEntry mapRow(ResultSet rs) throws SQLException { private ActiveSessionEntry mapRow(ResultSet rs) throws SQLException {
String sessionId = rs.getString("session_id"); String sessionId = rs.getString("session_id");
String login = rs.getString("login"); String login = rs.getString("login");
String sessionPwd = rs.getString("session_pwd"); String sessionKey = rs.getString("session_key");
String storagePwd = rs.getString("storage_pwd"); String storagePwd = rs.getString("storage_pwd");
long sessionCreatedAtMs = rs.getLong("session_created_at_ms"); long sessionCreatedAtMs = rs.getLong("session_created_at_ms");
long lastAuthirificatedAtMs = rs.getLong("last_authirificated_at_ms"); long lastAuthirificatedAtMs = rs.getLong("last_authirificated_at_ms");
@ -266,7 +254,7 @@ public final class ActiveSessionsDAO {
return new ActiveSessionEntry( return new ActiveSessionEntry(
sessionId, sessionId,
login, login,
sessionPwd, sessionKey,
storagePwd, storagePwd,
sessionCreatedAtMs, sessionCreatedAtMs,
lastAuthirificatedAtMs, lastAuthirificatedAtMs,

View File

@ -7,10 +7,14 @@ public class ActiveSessionEntry {
private String sessionId; private String sessionId;
private String login; private String login;
private String sessionPwd;
/** session_key: публичный ключ сессии (base64 от 32 байт). */
private String sessionKey;
private String storagePwd; private String storagePwd;
private long sessionCreatedAtMs; private long sessionCreatedAtMs;
private long lastAuthirificatedAtMs; private long lastAuthirificatedAtMs;
private String pushEndpoint; private String pushEndpoint;
private String pushP256dhKey; private String pushP256dhKey;
private String pushAuthKey; private String pushAuthKey;
@ -20,12 +24,11 @@ public class ActiveSessionEntry {
private String clientInfoFromRequest; private String clientInfoFromRequest;
private String userLanguage; private String userLanguage;
public ActiveSessionEntry() { public ActiveSessionEntry() { }
}
public ActiveSessionEntry(String sessionId, public ActiveSessionEntry(String sessionId,
String login, String login,
String sessionPwd, String sessionKey,
String storagePwd, String storagePwd,
long sessionCreatedAtMs, long sessionCreatedAtMs,
long lastAuthirificatedAtMs, long lastAuthirificatedAtMs,
@ -38,7 +41,7 @@ public class ActiveSessionEntry {
String userLanguage) { String userLanguage) {
this.sessionId = sessionId; this.sessionId = sessionId;
this.login = login; this.login = login;
this.sessionPwd = sessionPwd; this.sessionKey = sessionKey;
this.storagePwd = storagePwd; this.storagePwd = storagePwd;
this.sessionCreatedAtMs = sessionCreatedAtMs; this.sessionCreatedAtMs = sessionCreatedAtMs;
this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; this.lastAuthirificatedAtMs = lastAuthirificatedAtMs;
@ -57,8 +60,8 @@ public class ActiveSessionEntry {
public String getLogin() { return login; } public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; } public void setLogin(String login) { this.login = login; }
public String getSessionPwd() { return sessionPwd; } public String getSessionKey() { return sessionKey; }
public void setSessionPwd(String sessionPwd) { this.sessionPwd = sessionPwd; } public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; }
public String getStoragePwd() { return storagePwd; } public String getStoragePwd() { return storagePwd; }
public void setStoragePwd(String storagePwd) { this.storagePwd = storagePwd; } public void setStoragePwd(String storagePwd) { this.storagePwd = storagePwd; }

View File

@ -7,12 +7,22 @@ import shine.db.entities.ActiveSessionEntry;
/** /**
* ConnectionContext контекст состояния одного WebSocket-соединения. * 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 class ConnectionContext {
// Статусы аутентификации // Статусы аутентификации
public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован 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; // авторизованный пользователь public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
// Полный пользователь из БД (solana_users) // Полный пользователь из БД (solana_users)
@ -23,20 +33,38 @@ public class ConnectionContext {
/** /**
* Идентификатор сессии base64-строка от 32 байт. * Идентификатор сессии base64-строка от 32 байт.
* Заполняется после успешного входа (AUTH_STATUS_USER).
*/ */
private String sessionId; private String sessionId;
/**
* Секрет сессии (то, что хранится в active_sessions.session_pwd).
*/
private String sessionPwd;
/** /**
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge), * Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
* используется на шаге 2 для проверки подписи. * используется на шаге CreateAuthSession для проверки подписи deviceKey.
*/ */
private String authNonce; 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_* * См. константы AUTH_STATUS_*
@ -83,7 +111,7 @@ public class ConnectionContext {
return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; return solanaUserEntry != null ? solanaUserEntry.getLogin() : null;
} }
// --- sessionId / sessionPwd --- // --- sessionId ---
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
@ -93,14 +121,6 @@ public class ConnectionContext {
this.sessionId = sessionId; this.sessionId = sessionId;
} }
public String getSessionPwd() {
return sessionPwd;
}
public void setSessionPwd(String sessionPwd) {
this.sessionPwd = sessionPwd;
}
// --- authNonce --- // --- authNonce ---
public String getAuthNonce() { public String getAuthNonce() {
@ -111,6 +131,32 @@ public class ConnectionContext {
this.authNonce = authNonce; 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 --- // --- auth status ---
public int getAuthenticationStatus() { public int getAuthenticationStatus() {
@ -134,9 +180,12 @@ public class ConnectionContext {
activeSessionEntry = null; activeSessionEntry = null;
sessionId = null; sessionId = null;
sessionPwd = null;
authNonce = null; authNonce = null;
sessionLoginNonce = null;
sessionLoginSessionId = null;
sessionLoginNonceExpiresAtMs = 0;
authenticationStatus = AUTH_STATUS_NONE; authenticationStatus = AUTH_STATUS_NONE;
wsSession = null; wsSession = null;
} }

View File

@ -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.entyties.Net_Request;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; 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_AuthChallenge_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_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_AuthChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_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.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; 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.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_GetUserParam_Handler;
import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler; import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler;
import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_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(...) // Map.of(...) поддерживает максимум 10 пар => используем Map.ofEntries(...)
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries( private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
Map.entry("RefreshSession", new Net_RefreshSession_Handler()),
Map.entry("AddUser", new Net_AddUser_Handler()), Map.entry("AddUser", new Net_AddUser_Handler()),
// --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()), Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()), Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
Map.entry("ListSessions", new Net_ListSessions_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()), Map.entry("AddBlock", new Net_AddBlock_Handler()),
// --- userParams --- // --- userParams ---
@ -55,12 +74,19 @@ public final class JsonHandlerRegistry {
); );
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries( private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
Map.entry("RefreshSession", Net_RefreshSession_Request.class),
Map.entry("AddUser", Net_AddUser_Request.class), Map.entry("AddUser", Net_AddUser_Request.class),
// --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class), Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class), Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
Map.entry("ListSessions", Net_ListSessions_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), Map.entry("AddBlock", Net_AddBlock_Request.class),
// --- userParams --- // --- userParams ---

View File

@ -1,9 +1,11 @@
package server.logic.ws_protocol.JSON.handlers.auth; package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.*; import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; 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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SolanaUsersDAO; import shine.db.dao.SolanaUsersDAO;
@ -13,10 +15,18 @@ import java.security.SecureRandom;
import java.util.Base64; 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 { 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); SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
if (solanaUserEntry == null) { if (solanaUserEntry == null) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
@ -59,21 +67,15 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
); );
} }
// 3) Заполняем контекст пользователем
ctx.setSolanaUser(solanaUserEntry); ctx.setSolanaUser(solanaUserEntry);
// 3.1) Отмечаем, что по этому соединению начата авторификация
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
// 4) Генерируем одноразовый authNonce = base64(32 случайных байт)
byte[] buf = new byte[32]; byte[] buf = new byte[32];
RANDOM.nextBytes(buf); RANDOM.nextBytes(buf);
String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
// Сохраняем challenge в отдельном поле authNonce
ctx.setAuthNonce(authNonce); ctx.setAuthNonce(authNonce);
// 5) Формируем ответ
Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response();
resp.setOp(req.getOp()); resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());

View File

@ -4,10 +4,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext; 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_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; 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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import server.ws.WsConnectionUtils; import server.ws.WsConnectionUtils;
@ -18,31 +19,15 @@ import shine.db.entities.SolanaUserEntry;
import java.sql.SQLException; import java.sql.SQLException;
/** /**
* Хэндлер CloseActiveSession. * CloseActiveSession (v2) закрытие текущей или другой сессии.
* *
* Назначение: * Логика авторизации (v2):
* - закрыть одну из активных сессий пользователя: * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
* * либо явно указанную в sessionId, * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет.
* * либо текущую (если sessionId не задана).
*
* Допустимые состояния:
* - AUTH_STATUS_USER:
* * timeMs / signatureB64 могут быть пустыми.
* * Достаточно факта текущей авторизации.
*
* - AUTH_STATUS_AUTH_IN_PROGRESS:
* * требуется проверка подписи Ed25519 над строкой
* "AUTHORIFICATED:" + timeMs + authNonce
* (authNonce взят на шаге AuthChallenge и хранится в ctx.authNonce).
* * Если подпись корректна, можно закрывать сессию даже до полноценной
* установки новой сессии.
* *
* Закрытие: * Закрытие:
* - запись ActiveSession удаляется из БД; * - удаляем запись из БД
* - если по этой sessionId есть активное WebSocket-подключение: * - если по sessionId есть активный WS закрываем его
* * если это ДРУГОЕ подключение оно закрывается сразу;
* * если это ТЕКУЩЕЕ подключение сначала отправляется ответ 200,
* а закрытие выполняется в отдельном потоке с небольшой задержкой.
*/ */
public class Net_CloseActiveSession_Handler implements JsonMessageHandler { 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 { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; 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( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.UNVERIFIED, WireCodes.Status.UNVERIFIED,
"NOT_AUTHENTICATED", "NOT_AUTHENTICATED",
"Операция доступна только в состоянии авторизации или авторификации" "Операция доступна только для авторизованных пользователей"
); );
} }
SolanaUserEntry user = ctx.getSolanaUser(); SolanaUserEntry user = ctx.getSolanaUser();
String currentLogin = user.getLogin(); 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(); String targetSessionId = req.getSessionId();
if (targetSessionId == null || targetSessionId.isBlank()) { if (targetSessionId == null || targetSessionId.isBlank()) {
// Если sessionId не передана берём текущую активную if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) {
if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) {
targetSessionId = ctx.getActiveSession().getSessionId();
} else if (ctx.getSessionId() != null) {
targetSessionId = ctx.getSessionId(); targetSessionId = ctx.getSessionId();
} else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) {
targetSessionId = ctx.getActiveSession().getSessionId();
} else { } else {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
@ -156,10 +65,9 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
} }
} }
ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance();
ActiveSessionEntry targetSession; ActiveSessionEntry targetSession;
try { try {
targetSession = sessionsDao.getBySessionId(targetSessionId); targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId);
} catch (SQLException e) { } catch (SQLException e) {
log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e);
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
@ -190,50 +98,31 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId());
// Пытаемся удалить сессию из БД и закрыть соответствующее подключение
closeActiveSession(targetSessionId, ctx, isCurrentSession); closeActiveSession(targetSessionId, ctx, isCurrentSession);
// Ответ OK (payload станет {} в JsonInboundProcessor)
Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response();
resp.setOp(req.getOp()); resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
// Для текущей сессии WebSocket будет закрыт чуть позже в отдельном потоке,
// чтобы этот ответ успел уйти.
return resp; return resp;
} }
/**
* Закрытие активной сессии:
* - удаление записи из БД;
* - закрытие WebSocket-подключения, если оно существует.
*
* @param targetSessionId идентификатор сессии, которую надо закрыть
* @param currentCtx контекст текущего подключения (которое вызвало запрос)
* @param isCurrentSession true, если закрывается "эта же" сессия
*/
private void closeActiveSession(String targetSessionId, private void closeActiveSession(String targetSessionId,
ConnectionContext currentCtx, ConnectionContext currentCtx,
boolean isCurrentSession) { boolean isCurrentSession) {
ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance();
try { try {
sessionsDao.deleteBySessionId(targetSessionId); ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId);
} catch (SQLException e) { } catch (SQLException e) {
log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e);
// Логируем, но считаем, что для клиента сессия всё равно должна быть недействительна.
} }
ConnectionContext ctxToClose = ConnectionContext ctxToClose =
ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId);
if (ctxToClose == null) { if (ctxToClose == null) return;
return;
}
if (isCurrentSession && ctxToClose == currentCtx) { if (isCurrentSession && ctxToClose == currentCtx) {
// Это текущее подключение: закрываем после отправки ответа.
new Thread(() -> { new Thread(() -> {
try { Thread.sleep(50); } catch (InterruptedException ignored) {} try { Thread.sleep(50); } catch (InterruptedException ignored) {}
WsConnectionUtils.closeConnection( WsConnectionUtils.closeConnection(
@ -243,7 +132,6 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
); );
}, "CloseSession-" + targetSessionId).start(); }, "CloseSession-" + targetSessionId).start();
} else { } else {
// Другая сессия можно закрыть сразу
WsConnectionUtils.closeConnection( WsConnectionUtils.closeConnection(
ctxToClose, ctxToClose,
4000, 4000,

View File

@ -1,14 +1,15 @@
package server.logic.ws_protocol.JSON.handlers.auth; package server.logic.ws_protocol.JSON.handlers.auth;
import org.eclipse.jetty.websocket.api.Session;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.eclipse.jetty.websocket.api.Session;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext; 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_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; 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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import server.ws.WsConnectionUtils; import server.ws.WsConnectionUtils;
@ -20,37 +21,37 @@ import shine.geo.GeoLookupService;
import utils.crypto.Ed25519Util; import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.sql.SQLException;
import java.util.Base64; 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 { public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
public static final long ALLOWED_SKEW_MS = 30_000L; 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 @Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq;
if (ctx == null if (ctx == null
@ -93,6 +94,43 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; 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(); String signatureB64 = req.getSignatureB64();
if (signatureB64 == null || signatureB64.isBlank()) { if (signatureB64 == null || signatureB64.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
@ -107,7 +145,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
long timeMs = req.getTimeMs(); long timeMs = req.getTimeMs();
long nowMs = System.currentTimeMillis(); long nowMs = System.currentTimeMillis();
long diff = Math.abs(nowMs - timeMs); long diff = Math.abs(nowMs - timeMs);
if (diff > ALLOWED_SKEW_MS) { if (diff > ALLOWED_SKEW_MS) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
@ -125,15 +162,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
clientInfoFromClient = clientInfoFromClient.substring(0, 50); clientInfoFromClient = clientInfoFromClient.substring(0, 50);
} }
String pubKeyB64 = user.getDeviceKey(); String devicePubKeyB64 = user.getDeviceKey();
if (pubKeyB64 == null || pubKeyB64.isBlank()) { if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"NO_PUBKEY1", "NO_DEVICE_KEY",
"Отсутствует публичный ключ pubkey1 для пользователя" "Отсутствует deviceKey у пользователя"
); );
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no pubkey"); WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey");
return err; return err;
} }
@ -141,7 +178,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
boolean sigOk; boolean sigOk;
try { try {
sigOk = verifyAuthorificatedSignature(user, authNonce, timeMs, signatureB64); sigOk = verifyCreateSessionSignature(
user,
login,
authNonce,
timeMs,
sessionPubKeyB64,
storagePwd,
signatureB64
);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
@ -164,9 +209,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
// --- Генерируем настоящий секрет сессии (sessionPwd) и sessionId --- // --- генерируем sessionId ---
String newSessionPwd = generateRandomSecret(); String sessionId = generateRandom32B64Url();
String sessionId = generateRandomSessionId();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
// --- Сбор данных о клиенте (IP, UA, язык) --- // --- Сбор данных о клиенте (IP, UA, язык) ---
@ -174,11 +218,12 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession);
String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession);
String clientIp = null; String clientIp = "";
if (wsSession != null) { 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 { try {
GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); GeoLookupService.resolveCountryCityOrIpWithCache(clientIp);
} catch (Exception e) { } catch (Exception e) {
@ -186,7 +231,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
} }
} }
} }
if (clientIp == null) clientIp = "";
// --- создаём запись ActiveSession и сохраняем в БД --- // --- создаём запись ActiveSession и сохраняем в БД ---
ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance();
@ -196,7 +240,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
activeSessionEntry = new ActiveSessionEntry( activeSessionEntry = new ActiveSessionEntry(
sessionId, sessionId,
login, login,
newSessionPwd, sessionPubKeyB64, // session_key (pubkey)
storagePwd, storagePwd,
now, now,
now, now,
@ -225,8 +269,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
// --- обновляем контекст --- // --- обновляем контекст ---
ctx.setActiveSession(activeSessionEntry); ctx.setActiveSession(activeSessionEntry);
ctx.setSessionId(sessionId); ctx.setSessionId(sessionId);
ctx.setSessionPwd(newSessionPwd); // теперь в контексте хранится секрет сессии ctx.setAuthNonce(null);
ctx.setAuthNonce(null); // одноразовый nonce больше не нужен
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx); ActiveConnectionsRegistry.getInstance().register(ctx);
@ -237,25 +280,40 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setSessionId(sessionId); resp.setSessionId(sessionId);
resp.setSessionPwd(newSessionPwd);
return resp; return resp;
} }
/** private static boolean verifyCreateSessionSignature(
* Генерация случайного sessionId: base64-строка от 32 байт. SolanaUserEntry user,
*/ String login,
private String generateRandomSessionId() { 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]; byte[] buf = new byte[32];
RANDOM.nextBytes(buf); RANDOM.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
} }
/** private static byte[] decodeBase64Any(String s) throws IllegalArgumentException {
* Генерация случайного секрета (sessionPwd): base64-строка от 32 байт. // сначала url-safe, потом обычный
*/ try {
private String generateRandomSecret() { return Base64.getUrlDecoder().decode(s);
byte[] buf = new byte[32]; } catch (IllegalArgumentException ignore) {
RANDOM.nextBytes(buf); return Base64.getDecoder().decode(s);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); }
} }
} }

View File

@ -3,12 +3,13 @@ package server.logic.ws_protocol.JSON.handlers.auth;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext; 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_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; 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.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSessionEntry; import shine.db.entities.ActiveSessionEntry;
@ -20,16 +21,11 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Хэндлер ListSessions. * ListSessions (v2) список активных сессий.
* *
* Назначение: * Логика авторизации (v2):
* - вернуть список всех активных сессий текущего пользователя * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
* (по loginId из ctx/solanaUser). * - Никаких подписей здесь больше нет.
*
* Безопасность:
* - анонимный клиент NOT_AUTHENTICATED (UNVERIFIED);
* - AUTH_STATUS_USER достаточно факта авторизации;
* - AUTH_STATUS_AUTH_IN_PROGRESS требуется подпись, как в CreateAuthSession/CloseActiveSession.
*/ */
public class Net_ListSessions_Handler implements JsonMessageHandler { 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 { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq;
// 1) Проверяем, что вообще есть пользователь в контексте if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
if (ctx == null || ctx.getSolanaUser() == null) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.UNVERIFIED, WireCodes.Status.UNVERIFIED,
@ -52,81 +47,6 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
SolanaUserEntry user = ctx.getSolanaUser(); SolanaUserEntry user = ctx.getSolanaUser();
String currentLogin = user.getLogin(); 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<ActiveSessionEntry> sessions; List<ActiveSessionEntry> sessions;
try { try {
sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin);
@ -140,10 +60,9 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
); );
} }
// 4) Собираем DTO с геолокацией
List<SessionInfo> resultList = new ArrayList<>(); List<SessionInfo> resultList = new ArrayList<>();
for (ActiveSessionEntry s : sessions) { for (ActiveSessionEntry s : sessions) {
SessionInfo info = new Net_ListSessions_Response.SessionInfo(); SessionInfo info = new SessionInfo();
info.setSessionId(s.getSessionId()); info.setSessionId(s.getSessionId());
info.setClientInfoFromClient(s.getClientInfoFromClient()); info.setClientInfoFromClient(s.getClientInfoFromClient());
info.setClientInfoFromRequest(s.getClientInfoFromRequest()); info.setClientInfoFromRequest(s.getClientInfoFromRequest());
@ -156,7 +75,6 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
resultList.add(info); resultList.add(info);
} }
// 5) Формируем ответ
Net_ListSessions_Response resp = new Net_ListSessions_Response(); Net_ListSessions_Response resp = new Net_ListSessions_Response();
resp.setOp(req.getOp()); resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());

View File

@ -1,200 +1,30 @@
package server.logic.ws_protocol.JSON.handlers.auth; //package server.logic.ws_protocol.JSON.handlers.auth;
//
import org.slf4j.Logger; //import server.logic.ws_protocol.JSON.ConnectionContext;
import org.slf4j.LoggerFactory; //import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; //import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.ConnectionContext; //import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.*; //import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RefreshSession_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Request; //import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.entyties.Net_Response; //import server.logic.ws_protocol.WireCodes;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; //
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; ///**
import server.logic.ws_protocol.WireCodes; // * RefreshSession (v2) ОТКЛЮЧЕН.
import shine.db.dao.ActiveSessionsDAO; // *
import shine.db.dao.SolanaUsersDAO; // * Раньше это был "короткий вход" (1 запрос) по sessionId+sessionPwd.
import shine.db.entities.ActiveSessionEntry; // * Теперь вход всегда 2 шага: SessionChallenge -> SessionLogin (подпись sessionKey).
import shine.db.entities.SolanaUserEntry; // */
import shine.geo.ClientInfoService; //public class Net_RefreshSession_Handler implements JsonMessageHandler {
import shine.geo.GeoLookupService; //
// @Override
import java.sql.SQLException; // public Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception {
// Net_RefreshSession_Request req = (Net_RefreshSession_Request) request;
/** //
* Хэндлер RefreshSession. // return NetExceptionResponseFactory.error(
* // req,
* При успешной проверке sessionId + sessionPwd: // WireCodes.Status.GONE, // 410
* - подтягивает пользователя по loginId из сессии; // "DISABLED_V2",
* - заполняет ConnectionContext; // "RefreshSession отключён в v2. Используй SessionChallenge + SessionLogin."
* - обновляет 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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -5,35 +5,20 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** /**
* Запрос CloseActiveSession закрытие активной сессии пользователя. * Запрос CloseActiveSession закрытие активной сессии пользователя.
* *
* Допустимые режимы: * Новая логика (v2):
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
* - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет.
* *
* 1) Пользователь уже авторизован (AUTH_STATUS_USER): * payload:
* - поле sessionId: * {
* * если заполнено закрывается указанная сессия пользователя; * "sessionId": "..." // опционально; если пусто закрываем текущую
* * если пустое закрывается текущая авторизованная сессия * }
* (та, в рамках которой выполняется запрос).
* - поля timeMs и signatureB64 могут быть пустыми и игнорируются.
*
* 2) Пользователь в статусе AUTH_STATUS_AUTH_IN_PROGRESS:
* - требуется дополнительно подтвердить владение ключом:
* * timeMs время на клиенте (мс с 1970-01-01),
* * signatureB64 подпись Ed25519 над строкой
* "AUTHORIFICATED:" + timeMs + authNonce.
* - authNonce берётся из шага 1 (AuthChallenge) и хранится в ctx.authNonce.
* - если подпись корректна, сервер закрывает указанную sessionId (или текущую,
* если sessionId не задана) и рвёт соответствующее WebSocket-подключение.
*/ */
public class Net_CloseActiveSession_Request extends Net_Request { public class Net_CloseActiveSession_Request extends Net_Request {
/** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */
private String sessionId; private String sessionId;
/** Время на стороне клиента (мс с 1970-01-01). Используется при AUTH_IN_PROGRESS. */
private long timeMs;
/** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */
private String signatureB64;
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
} }
@ -41,20 +26,4 @@ public class Net_CloseActiveSession_Request extends Net_Request {
public void setSessionId(String sessionId) { public void setSessionId(String sessionId) {
this.sessionId = 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;
}
} }

View File

@ -3,40 +3,31 @@ package 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_Request;
/** /**
* Шаг 2 авторизации: подтверждение владения ключом и установка сессии. * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey.
* *
* Клиент: * Шаги:
* 1) получает от сервера authNonce на шаге 1; * 1) AuthChallenge(login) -> authNonce
* 2) генерирует свой StoragePwd (base64 от 32 байт); * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo)
* 3) формирует строку для подписи:
* "AUTHORIFICATED:" + timeMs + authNonce
* 4) подписывает эту строку своим приватным ключом (pubkey1),
* отправляет подпись и StoragePwd на сервер.
* *
* Дополнительно: * Подпись deviceKey делается над строкой (UTF-8):
* - clientInfo короткая строка (до 50 символов) с данными об устройстве/клиенте. * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd}
* *
* Формат входящего JSON: * Важно:
* { * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64).
* "op": "CreateAuthSession", * - В БД active_sessions.session_key хранится sessionPubKeyB64.
* "requestId": "...",
* "payload": {
* "storagePwd": "base64-строка-от-32-байт",
* "timeMs": 1733310000000,
* "signatureB64": "base64-подпись-Ed25519",
* "clientInfo": "Chrome/Android" // опционально, до 50 символов
* }
* }
*/ */
public class Net_CreateAuthSession_Request extends Net_Request { public class Net_CreateAuthSession_Request extends Net_Request {
/** Клиентский пароль для хранения данных (base64 от 32 байт). */ /** Клиентский пароль для хранения данных (base64url от 32 байт). */
private String storagePwd; private String storagePwd;
/** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */
private String sessionPubKeyB64;
/** Время на стороне клиента (мс с 1970-01-01). */ /** Время на стороне клиента (мс с 1970-01-01). */
private long timeMs; private long timeMs;
/** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */ /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */
private String signatureB64; private String signatureB64;
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
@ -50,6 +41,14 @@ public class Net_CreateAuthSession_Request extends Net_Request {
this.storagePwd = storagePwd; this.storagePwd = storagePwd;
} }
public String getSessionPubKeyB64() {
return sessionPubKeyB64;
}
public void setSessionPubKeyB64(String sessionPubKeyB64) {
this.sessionPubKeyB64 = sessionPubKeyB64;
}
public long getTimeMs() { public long getTimeMs() {
return timeMs; return timeMs;
} }

View File

@ -3,10 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response;
/** /**
* Ответ на CreateAuthSession. * Ответ на CreateAuthSession (v2).
* *
* При успехе сервер создаёт запись в active_sessions * При успехе сервер создаёт запись в active_sessions
* и возвращает идентификатор сессии sessionId и секрет сессии sessionPwd. * и возвращает идентификатор сессии sessionId.
* *
* JSON: * JSON:
* { * {
@ -14,19 +14,15 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
* "requestId": "...", * "requestId": "...",
* "status": 200, * "status": 200,
* "payload": { * "payload": {
* "sessionId": "base64-строка-от-32-байт", * "sessionId": "base64url(32)"
* "sessionPwd": "base64-строка-от-32-байт"
* } * }
* } * }
*/ */
public class Net_CreateAuthSession_Response extends Net_Response { public class Net_CreateAuthSession_Response extends Net_Response {
/** Идентификатор сессии, base64 от 32 байт. */ /** Идентификатор сессии, base64url от 32 байт. */
private String sessionId; private String sessionId;
/** Секрет сессии, base64 от 32 байт. */
private String sessionPwd;
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
} }
@ -34,12 +30,4 @@ public class Net_CreateAuthSession_Response extends Net_Response {
public void setSessionId(String sessionId) { public void setSessionId(String sessionId) {
this.sessionId = sessionId; this.sessionId = sessionId;
} }
public String getSessionPwd() {
return sessionPwd;
}
public void setSessionPwd(String sessionPwd) {
this.sessionPwd = sessionPwd;
}
} }

View File

@ -5,50 +5,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** /**
* Запрос ListSessions список активных сессий пользователя. * Запрос ListSessions список активных сессий пользователя.
* *
* Режимы безопасности такие же, как у CloseActiveSession: * Новая логика (v2):
* * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
* 1) Пользователь уже авторизован (AUTH_STATUS_USER): * - Пустой payload.
* - поля 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
* }
* }
*/ */
public class Net_ListSessions_Request extends Net_Request { 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;
}
} }

View File

@ -1,48 +1,36 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties; //package 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_Request;
//
/** ///**
* Запрос RefreshSession. // * Запрос RefreshSession.
* // *
* Используется для повторного входа без повторной подписи: // * В новой версии (v2) RefreshSession ОТКЛЮЧЕН.
* клиент хранит sessionId и sessionPwd, которые получил на шаге 2. // * Оставлен временно для совместимости, handler вернёт 410 GONE.
* // */
* JSON (payload): //public class Net_RefreshSession_Request extends Net_Request {
* { //
* "sessionId": "base64-id-сессии", // private String sessionId;
* "sessionPwd": "base64-sessionPwd", // private String sessionPwd;
* "clientInfo": "до 50 символов, краткая строка об устройстве" // private String clientInfo;
* } //
*/ // public String getSessionId() {
public class Net_RefreshSession_Request extends Net_Request { // return sessionId;
// }
private String sessionId; //
private String sessionPwd; // public void setSessionId(String sessionId) {
// this.sessionId = sessionId;
/** // }
* Краткая строка с информацией об устройстве/клиенте, до 50 символов. //
* Например: "PWA/Chrome/Android". // public String getSessionPwd() {
*/ // return sessionPwd;
private String clientInfo; // }
//
public String getSessionId() { // public void setSessionPwd(String sessionPwd) {
return sessionId; // this.sessionPwd = sessionPwd;
} // }
//
public void setSessionId(String sessionId) { // public String getClientInfo() { return clientInfo; }
this.sessionId = sessionId; //
} // public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; }
//}
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; }
}

View File

@ -1,33 +1,23 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties; //package server.logic.ws_protocol.JSON.handlers.auth.entyties;
//
import server.logic.ws_protocol.JSON.entyties.Net_Response; //import server.logic.ws_protocol.JSON.entyties.Net_Response;
//
/** ///**
* Успешный ответ на RefreshSession. // * Ответ на RefreshSession.
* // *
* Дополнительно к статусу 200 сервер возвращает storagePwd, // * В новой версии (v2) RefreshSession ОТКЛЮЧЕН.
* чтобы клиент мог восстановить/синхронизировать локальное хранилище. // * Этот класс можно оставить временно для совместимости сериализации,
* // * но handler будет возвращать 410 GONE.
* JSON: // */
* { //public class Net_RefreshSession_Response extends Net_Response {
* "op": "RefreshSession", //
* "requestId": "...", // private String storagePwd;
* "status": 200, //
* "payload": { // public String getStoragePwd() {
* "storagePwd": "base64-строка-от-32-байт" // return storagePwd;
* } // }
* } //
*/ // public void setStoragePwd(String storagePwd) {
public class Net_RefreshSession_Response extends Net_Response { // this.storagePwd = storagePwd;
// }
/** Пароль хранилища, сохранённый в сессии (storagePwd). */ //}
private String storagePwd;
public String getStoragePwd() {
return storagePwd;
}
public void setStoragePwd(String storagePwd) {
this.storagePwd = storagePwd;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -13,11 +13,16 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
/** /**
* IT_02_Sessions * IT_02_Sessions (v2)
* *
* Цель: * Цель:
* - проверить создание/листинг/refresh/close * - проверить создание/листинг/вход-в-сессию(2 шага)/close
* - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) * - и после завершения оставить в БД 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 { public class IT_02_Sessions {
@ -31,110 +36,65 @@ public class IT_02_Sessions {
} }
public static String run() { public static String run() {
TestResult r = new TestResult("IT_02_Sessions"); TestResult r = new TestResult("IT_02_Sessions(v2)");
Duration t = Duration.ofSeconds(5); Duration t = Duration.ofSeconds(5);
String s1Id, s1Pwd; Session s1, s2, s3;
String s2Id, s2Pwd;
String s3Id, s3Pwd;
try { try {
// 1) Создаём 3 сессии (каждая отдельным соединением, чтобы не зависеть от состояния WS) // 1) Создаём 3 сессии (каждая отдельным соединением)
Session s1 = createSession(LOGIN, t, r, "S1"); s1 = createSession(LOGIN, t, r, "S1");
s1Id = s1.sessionId; s1Pwd = s1.sessionPwd; s2 = createSession(LOGIN, t, r, "S2");
s3 = createSession(LOGIN, t, r, "S3");
Session s2 = createSession(LOGIN, t, r, "S2"); // 2) Входим в S1 (2 шага) и делаем ListSessions (AUTH_STATUS_USER) должны быть S1,S2,S3
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
try (WsSession ws = WsSession.open()) { try (WsSession ws = WsSession.open()) {
String nonceResp = ws.call("AuthChallenge(list)", JsonBuilders.authChallenge(LOGIN), t); sessionLogin2Steps(ws, s1, t, "Login(S1)", r);
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(list) must be 200");
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce, "authNonce must not be null");
long timeMs = System.currentTimeMillis(); String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t);
String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN)); assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200");
String listResp = ws.call("ListSessions(AUTH_IN_PROGRESS)", JsonBuilders.listSessions(timeMs, sig), t);
assertEquals(200, JsonParsers.status(listResp), "ListSessions must be 200");
List<String> ids = JsonParsers.sessionIds(listResp); List<String> 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(s1.sessionId), "Must contain S1");
assertTrue(ids.contains(s2Id), "Must contain S2"); assertTrue(ids.contains(s2.sessionId), "Must contain S2");
assertTrue(ids.contains(s3Id), "Must contain S3"); assertTrue(ids.contains(s3.sessionId), "Must contain S3");
r.ok("Проверка OK: список содержит S1,S2,S3"); r.ok("Проверка OK: список содержит S1,S2,S3");
} }
// 3) RefreshSession(S1) -> после refresh в этом же соединении делаем ListSessions(AUTH_STATUS_USER) (timeMs=0) // 3) Проверяем CloseActiveSession так, чтобы итогом всё равно осталось 3 сессии:
try (WsSession ws = WsSession.open()) { // создаём TEMP, логинимся в S1, закрываем TEMP, убеждаемся что S1,S2,S3 остались.
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<String> 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 остались.
Session temp = createSession(LOGIN, t, r, "TEMP"); Session temp = createSession(LOGIN, t, r, "TEMP");
String tempId = temp.sessionId;
try (WsSession ws = WsSession.open()) { try (WsSession ws = WsSession.open()) {
String nonceResp = ws.call("AuthChallenge(close TEMP)", JsonBuilders.authChallenge(LOGIN), t); sessionLogin2Steps(ws, s1, t, "Login(S1) for close", r);
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(close TEMP) must be 200");
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce);
long timeMs = System.currentTimeMillis(); String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(temp.sessionId, 0L, ""), t);
String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN));
String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(tempId, timeMs, sig), t);
assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200"); assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200");
r.ok("CloseActiveSession(TEMP): OK"); 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()) { try (WsSession ws = WsSession.open()) {
String nonceResp = ws.call("AuthChallenge(final list)", JsonBuilders.authChallenge(LOGIN), t); sessionLogin2Steps(ws, s1, t, "Final Login(S1)", r);
assertEquals(200, JsonParsers.status(nonceResp));
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce);
long timeMs = System.currentTimeMillis(); String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t);
String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN));
String listResp = ws.call("ListSessions(final AUTH_IN_PROGRESS)", JsonBuilders.listSessions(timeMs, sig), t);
assertEquals(200, JsonParsers.status(listResp)); assertEquals(200, JsonParsers.status(listResp));
List<String> ids = JsonParsers.sessionIds(listResp); List<String> ids = JsonParsers.sessionIds(listResp);
r.ok("Final ListSessions: " + ids); r.ok("Final ListSessions: " + ids);
assertTrue(ids.contains(s1Id)); assertTrue(ids.contains(s1.sessionId));
assertTrue(ids.contains(s2Id)); assertTrue(ids.contains(s2.sessionId));
assertTrue(ids.contains(s3Id)); assertTrue(ids.contains(s3.sessionId));
assertFalse(ids.contains(tempId)); assertFalse(ids.contains(temp.sessionId));
r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)"); r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)");
} }
} catch (Throwable e) { } catch (Throwable e) {
r.fail("IT_02_Sessions упал: " + e.getMessage()); r.fail("IT_02_Sessions(v2) упал: " + e.getMessage());
} }
return r.summaryLine(); return r.summaryLine();
@ -142,24 +102,56 @@ public class IT_02_Sessions {
private static Session createSession(String login, Duration t, TestResult r, String label) { private static Session createSession(String login, Duration t, TestResult r, String label) {
try (WsSession ws = WsSession.open()) { try (WsSession ws = WsSession.open()) {
// шаг 1: AuthChallenge
String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t); String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t);
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200"); assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200");
String nonce = JsonParsers.authNonce(nonceResp); String authNonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce, "authNonce must not be null for " + label); 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"); assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200");
String sid = JsonParsers.sessionId(createResp); String sid = JsonParsers.sessionId(createResp);
String spw = JsonParsers.sessionPwd(createResp);
assertNotNull(sid, "sessionId must not be null"); assertNotNull(sid, "sessionId must not be null");
assertNotNull(spw, "sessionPwd must not be null");
r.ok("Создана сессия " + label + ": sessionId=" + sid); 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) {}
} }

View File

@ -4,7 +4,6 @@ import test.it.cases.IT_01_AddUser;
import test.it.cases.IT_02_Sessions; import test.it.cases.IT_02_Sessions;
import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_03_AddBlock_NoAuth;
import test.it.cases.IT_04_UserParams_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth;
import test.it.cases.IT_05_ListSubscribedChannels_200;
import test.it.utils.log.TestLog; import test.it.utils.log.TestLog;
import java.util.ArrayList; 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 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 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 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)"); TestLog.title("IT RUN RESULT (per test)");
for (String s : summaries) System.out.println(s); for (String s : summaries) System.out.println(s);

View File

@ -58,9 +58,11 @@ public final class JsonBuilders {
""".formatted(requestId, login); """.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(); long timeMs = System.currentTimeMillis();
byte[] devicePriv = TestConfig.getDevicePrivatKey(login); byte[] devicePriv = TestConfig.getDevicePrivatKey(login);
String sigB64 = signAuthorificated(authNonce, timeMs, devicePriv); String sigB64 = signAuthorificated(authNonce, timeMs, devicePriv);
@ -72,12 +74,56 @@ public final class JsonBuilders {
"requestId": "%s", "requestId": "%s",
"payload": { "payload": {
"storagePwd": "%s", "storagePwd": "%s",
"sessionPubKeyB64": "%s",
"timeMs": %d, "timeMs": %d,
"signatureB64": "%s", "signatureB64": "%s",
"clientInfo": "%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 ---------------- // ---------------- ListSessions ----------------
@ -97,23 +143,6 @@ public final class JsonBuilders {
""".formatted(requestId, timeMs, signatureB64); """.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 ---------------- // ---------------- CloseActiveSession ----------------
public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) { public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) {
@ -145,7 +174,6 @@ public final class JsonBuilders {
""".formatted(requestId, login); """.formatted(requestId, login);
} }
/** /**
* Подпись для режима AUTH_IN_PROGRESS: * Подпись для режима AUTH_IN_PROGRESS:
* preimage = "AUTHORIFICATED:" + timeMs + authNonce * preimage = "AUTHORIFICATED:" + timeMs + authNonce
@ -157,4 +185,16 @@ public final class JsonBuilders {
byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); byte[] sig = Ed25519Util.sign(preimage, devicePrivKey);
return Base64.getEncoder().encodeToString(sig); 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);
}
} }

View File

@ -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) { public static String sessionId(String json) {
try { try {
JsonNode root = MAPPER.readTree(json); JsonNode root = MAPPER.readTree(json);
@ -41,6 +53,7 @@ public final class JsonParsers {
} }
} }
// оставляю для совместимости с другими тестами, но в IT_02(v2) больше не используется
public static String sessionPwd(String json) { public static String sessionPwd(String json) {
try { try {
JsonNode root = MAPPER.readTree(json); JsonNode root = MAPPER.readTree(json);