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

View File

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

View File

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

View File

@ -7,12 +7,22 @@ import shine.db.entities.ActiveSessionEntry;
/**
* ConnectionContext контекст состояния одного WebSocket-соединения.
* Живёт ровно столько же, сколько живёт подключение.
*
* Важно (v2):
* - Авторизация всегда 2 шага:
* A) Создание новой сессии через deviceKey:
* AuthChallenge(login) -> ctx.authNonce
* CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession
*
* B) Вход в существующую сессию через sessionKey:
* SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt
* SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER
*/
public class ConnectionContext {
// Статусы аутентификации
public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // получен AuthChallenge
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
// Полный пользователь из БД (solana_users)
@ -23,20 +33,38 @@ public class ConnectionContext {
/**
* Идентификатор сессии base64-строка от 32 байт.
* Заполняется после успешного входа (AUTH_STATUS_USER).
*/
private String sessionId;
/**
* Секрет сессии (то, что хранится в active_sessions.session_pwd).
*/
private String sessionPwd;
/**
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
* используется на шаге 2 для проверки подписи.
* используется на шаге CreateAuthSession для проверки подписи deviceKey.
*/
private String authNonce;
/* ===================== SessionLogin challenge (v2) ===================== */
/**
* Одноразовый nonce, выданный на шаге SessionChallenge(sessionId),
* используется на шаге SessionLogin для проверки подписи sessionKey.
*/
private String sessionLoginNonce;
/**
* sessionId, для которого был выдан sessionLoginNonce.
* Нужен, чтобы SessionLogin не мог "подставить" другой sessionId.
*/
private String sessionLoginSessionId;
/**
* Время истечения sessionLoginNonce (мс с 1970-01-01).
* Если текущее время > expiresAt, то nonce считается недействительным.
*/
private long sessionLoginNonceExpiresAtMs;
/* ====================================================================== */
/**
* Текущий статус аутентификации.
* См. константы AUTH_STATUS_*
@ -83,7 +111,7 @@ public class ConnectionContext {
return solanaUserEntry != null ? solanaUserEntry.getLogin() : null;
}
// --- sessionId / sessionPwd ---
// --- sessionId ---
public String getSessionId() {
return sessionId;
@ -93,14 +121,6 @@ public class ConnectionContext {
this.sessionId = sessionId;
}
public String getSessionPwd() {
return sessionPwd;
}
public void setSessionPwd(String sessionPwd) {
this.sessionPwd = sessionPwd;
}
// --- authNonce ---
public String getAuthNonce() {
@ -111,6 +131,32 @@ public class ConnectionContext {
this.authNonce = authNonce;
}
// --- sessionLoginNonce (v2) ---
public String getSessionLoginNonce() {
return sessionLoginNonce;
}
public void setSessionLoginNonce(String sessionLoginNonce) {
this.sessionLoginNonce = sessionLoginNonce;
}
public String getSessionLoginSessionId() {
return sessionLoginSessionId;
}
public void setSessionLoginSessionId(String sessionLoginSessionId) {
this.sessionLoginSessionId = sessionLoginSessionId;
}
public long getSessionLoginNonceExpiresAtMs() {
return sessionLoginNonceExpiresAtMs;
}
public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) {
this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs;
}
// --- auth status ---
public int getAuthenticationStatus() {
@ -134,9 +180,12 @@ public class ConnectionContext {
activeSessionEntry = null;
sessionId = null;
sessionPwd = null;
authNonce = null;
sessionLoginNonce = null;
sessionLoginSessionId = null;
sessionLoginNonceExpiresAtMs = 0;
authenticationStatus = AUTH_STATUS_NONE;
wsSession = null;
}

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.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_RefreshSession_Handler;
// --- NEW v2 session login ---
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler;
// --- auth entities ---
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RefreshSession_Request;
// --- NEW v2 entities ---
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler;
import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler;
import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler;
@ -37,12 +49,19 @@ public final class JsonHandlerRegistry {
// Map.of(...) поддерживает максимум 10 пар => используем Map.ofEntries(...)
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
Map.entry("RefreshSession", new Net_RefreshSession_Handler()),
Map.entry("AddUser", new Net_AddUser_Handler()),
// --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
Map.entry("ListSessions", new Net_ListSessions_Handler()),
// --- login to existing session in 2 steps ---
Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()),
Map.entry("SessionLogin", new Net_SessionLogin_Handler()),
// --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()),
// --- userParams ---
@ -55,12 +74,19 @@ public final class JsonHandlerRegistry {
);
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
Map.entry("RefreshSession", Net_RefreshSession_Request.class),
Map.entry("AddUser", Net_AddUser_Request.class),
// --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
Map.entry("ListSessions", Net_ListSessions_Request.class),
// --- NEW v2 ---
Map.entry("SessionChallenge", Net_SessionChallenge_Request.class),
Map.entry("SessionLogin", Net_SessionLogin_Request.class),
// --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class),
// --- userParams ---

View File

@ -1,9 +1,11 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.*;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.*;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SolanaUsersDAO;
@ -13,10 +15,18 @@ import java.security.SecureRandom;
import java.util.Base64;
/**
* Шаг 1 авторизации: запрос выдачи временного nonce (authNonce).
* AuthChallenge (v2) шаг 1 создания новой сессии.
*
* Клиент по логину просит сервер сгенерировать случайный authNonce,
* который будет использован на втором шаге при подписи.
* Логика авторизации (v2):
* - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя.
* - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге:
* CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...))
*
* Что делает:
* 1) Проверяет login.
* 2) Находит пользователя (solana_users).
* 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS.
* 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce.
*/
public class Net_AuthChallenge_Handler implements JsonMessageHandler {
@ -47,9 +57,7 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
);
}
// 2) Ищем пользователя в локальной БД
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
if (solanaUserEntry == null) {
return NetExceptionResponseFactory.error(
req,
@ -59,21 +67,15 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
);
}
// 3) Заполняем контекст пользователем
ctx.setSolanaUser(solanaUserEntry);
// 3.1) Отмечаем, что по этому соединению начата авторификация
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
// 4) Генерируем одноразовый authNonce = base64(32 случайных байт)
byte[] buf = new byte[32];
RANDOM.nextBytes(buf);
String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
// Сохраняем challenge в отдельном поле authNonce
ctx.setAuthNonce(authNonce);
// 5) Формируем ответ
Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());

View File

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

View File

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

View File

@ -3,12 +3,13 @@ package server.logic.ws_protocol.JSON.handlers.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.*;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSessionEntry;
@ -20,16 +21,11 @@ import java.util.ArrayList;
import java.util.List;
/**
* Хэндлер ListSessions.
* ListSessions (v2) список активных сессий.
*
* Назначение:
* - вернуть список всех активных сессий текущего пользователя
* (по loginId из ctx/solanaUser).
*
* Безопасность:
* - анонимный клиент NOT_AUTHENTICATED (UNVERIFIED);
* - AUTH_STATUS_USER достаточно факта авторизации;
* - AUTH_STATUS_AUTH_IN_PROGRESS требуется подпись, как в CreateAuthSession/CloseActiveSession.
* Логика авторизации (v2):
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
* - Никаких подписей здесь больше нет.
*/
public class Net_ListSessions_Handler implements JsonMessageHandler {
@ -39,8 +35,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq;
// 1) Проверяем, что вообще есть пользователь в контексте
if (ctx == null || ctx.getSolanaUser() == null) {
if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
@ -52,81 +47,6 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
SolanaUserEntry user = ctx.getSolanaUser();
String currentLogin = user.getLogin();
int authStatus = ctx.getAuthenticationStatus();
if (authStatus != ConnectionContext.AUTH_STATUS_USER
&& authStatus != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"BAD_AUTH_STATUS",
"Операция ListSessions недоступна в текущем статусе аутентификации"
);
}
// 2) Если мы ещё на шаге AUTH_IN_PROGRESS проверяем подпись
if (authStatus == ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) {
String authNonce = ctx.getAuthNonce();
if (authNonce == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"NO_STEP1_CONTEXT",
"Шаг 1 авторизации не был корректно выполнен для данного соединения"
);
}
long timeMs = req.getTimeMs();
String signatureB64 = req.getSignatureB64();
if (signatureB64 == null || signatureB64.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"EMPTY_SIGNATURE",
"Подпись обязательна при статусе AUTH_IN_PROGRESS"
);
}
long nowMs = System.currentTimeMillis();
long diff = Math.abs(nowMs - timeMs);
if (diff > Net_CreateAuthSession__Handler.ALLOWED_SKEW_MS) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"TIME_SKEW",
"Время клиента отличается от сервера более чем на 30 секунд"
);
}
boolean sigOk;
try {
sigOk = Net_CreateAuthSession__Handler.verifyAuthorificatedSignature(
user,
authNonce,
timeMs,
signatureB64
);
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BASE64",
"Некорректный формат Base64 для ключа или подписи"
);
}
if (!sigOk) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"BAD_SIGNATURE",
"Подпись не прошла проверку"
);
}
}
// 3) Тянем все активные сессии пользователя из БД
List<ActiveSessionEntry> sessions;
try {
sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin);
@ -140,10 +60,9 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
);
}
// 4) Собираем DTO с геолокацией
List<SessionInfo> resultList = new ArrayList<>();
for (ActiveSessionEntry s : sessions) {
SessionInfo info = new Net_ListSessions_Response.SessionInfo();
SessionInfo info = new SessionInfo();
info.setSessionId(s.getSessionId());
info.setClientInfoFromClient(s.getClientInfoFromClient());
info.setClientInfoFromRequest(s.getClientInfoFromRequest());
@ -156,7 +75,6 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
resultList.add(info);
}
// 5) Формируем ответ
Net_ListSessions_Response resp = new Net_ListSessions_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());

View File

@ -1,200 +1,30 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.*;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import shine.geo.ClientInfoService;
import shine.geo.GeoLookupService;
import java.sql.SQLException;
/**
* Хэндлер RefreshSession.
*
* При успешной проверке sessionId + sessionPwd:
* - подтягивает пользователя по loginId из сессии;
* - заполняет ConnectionContext;
* - обновляет lastAuthirificatedAtMs и метаданные сессии в БД;
* - возвращает storagePwd в payload.
*/
public class Net_RefreshSession_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_RefreshSession_Handler.class);
// максимум 50 символов для clientInfo от клиента
private static final int CLIENT_INFO_MAX_LEN = 50;
@Override
public Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception {
Net_RefreshSession_Request req = (Net_RefreshSession_Request) request;
String sessionId = req.getSessionId();
String sessionPwd = req.getSessionPwd();
String clientInfoFromClient = trimClientInfo(req.getClientInfo());
if (sessionId == null || sessionId.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SESSION_ID",
"Пустой идентификатор сессии"
);
}
if (sessionPwd == null || sessionPwd.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SESSION_PWD",
"Пустой пароль сессии"
);
}
ActiveSessionsDAO sessionsDao = ActiveSessionsDAO.getInstance();
ActiveSessionEntry session;
try {
session = sessionsDao.getBySessionId(sessionId);
} catch (SQLException e) {
log.error("Ошибка БД при поиске сессии sessionId={}", sessionId, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка доступа к базе данных"
);
}
if (session == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"SESSION_NOT_FOUND",
"Сессия не найдена"
);
}
String dbPwd = session.getSessionPwd();
if (dbPwd == null || !dbPwd.equals(sessionPwd)) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"SESSION_PWD_MISMATCH",
"Неверный пароль сессии"
);
}
// --- вытаскиваем пользователя по login из сессии ---
SolanaUserEntry solanaUserEntry;
String login = session.getLogin();
try {
SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
solanaUserEntry = usersDao.getByLogin(login);
} catch (SQLException e) {
log.error("Ошибка БД при поиске пользователя по login={} из сессии", login, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR_USER_LOOKUP",
"Ошибка доступа к базе данных при получении пользователя для сессии"
);
}
if (solanaUserEntry == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"USER_NOT_FOUND_FOR_SESSION",
"Пользователь для данной сессии не найден"
);
}
// --- собираем данные о клиенте из WebSocket-запроса ---
String clientIp = null;
String clientInfoFromRequest = null;
String userLanguage = null;
if (ctx != null && ctx.getWsSession() != null) {
// Нормальное получение IP-адреса клиента
clientIp = ClientInfoService.extractClientIp(ctx.getWsSession());
// Сделать запрос геолокации и никуда её не сохранять:
// вызов с кэшированием в БД, нужно только для прогрева кэша.
if (clientIp != null && !clientIp.isBlank()) {
try {
GeoLookupService.resolveCountryCityOrIpWithCache(clientIp);
} catch (Exception e) {
// Геолокация не критична, просто логируем на debug/trace при желании
log.debug("Geo lookup failed for ip={}", clientIp, e);
}
}
clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession());
userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession());
}
long nowMs = System.currentTimeMillis();
// --- обновляем запись в БД (lastAuth + мета) ---
try {
sessionsDao.updateOnRefresh(
sessionId,
nowMs,
clientIp,
clientInfoFromClient,
clientInfoFromRequest,
userLanguage
);
} catch (SQLException e) {
log.error("Ошибка БД при обновлении метаданных сессии sessionId={}", sessionId, e);
// не роняем авторизацию, но логируем
}
// Также обновим объект session в памяти (если дальше кто-то его использует)
session.setLastAuthirificatedAtMs(nowMs);
session.setClientIp(clientIp);
session.setClientInfoFromClient(clientInfoFromClient);
session.setClientInfoFromRequest(clientInfoFromRequest);
session.setUserLanguage(userLanguage);
// --- обновляем контекст соединения ---
if (ctx != null) {
ctx.setActiveSession(session);
ctx.setSolanaUser(solanaUserEntry);
ctx.setSessionId(sessionId);
ctx.setSessionPwd(sessionPwd);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
// Регистрируем это подключение в глобальном реестре активных соединений
ActiveConnectionsRegistry.getInstance().register(ctx);
}
// --- ответ OK + storagePwd ---
Net_RefreshSession_Response resp = new Net_RefreshSession_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setStoragePwd(session.getStoragePwd());
return resp;
}
private String trimClientInfo(String info) {
if (info == null) return null;
info = info.trim();
if (info.length() > CLIENT_INFO_MAX_LEN) {
return info.substring(0, CLIENT_INFO_MAX_LEN);
}
return info;
}
}
//package server.logic.ws_protocol.JSON.handlers.auth;
//
//import server.logic.ws_protocol.JSON.ConnectionContext;
//import server.logic.ws_protocol.JSON.entyties.Net_Request;
//import server.logic.ws_protocol.JSON.entyties.Net_Response;
//import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
//import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RefreshSession_Request;
//import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
//import server.logic.ws_protocol.WireCodes;
//
///**
// * RefreshSession (v2) ОТКЛЮЧЕН.
// *
// * Раньше это был "короткий вход" (1 запрос) по sessionId+sessionPwd.
// * Теперь вход всегда 2 шага: SessionChallenge -> SessionLogin (подпись sessionKey).
// */
//public class Net_RefreshSession_Handler implements JsonMessageHandler {
//
// @Override
// public Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception {
// Net_RefreshSession_Request req = (Net_RefreshSession_Request) request;
//
// return NetExceptionResponseFactory.error(
// req,
// WireCodes.Status.GONE, // 410
// "DISABLED_V2",
// "RefreshSession отключён в v2. Используй SessionChallenge + SessionLogin."
// );
// }
//}

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

View File

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

View File

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

View File

@ -5,50 +5,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос ListSessions список активных сессий пользователя.
*
* Режимы безопасности такие же, как у CloseActiveSession:
*
* 1) Пользователь уже авторизован (AUTH_STATUS_USER):
* - поля timeMs и signatureB64 могут быть пустыми и игнорируются.
*
* 2) Пользователь в статусе AUTH_STATUS_AUTH_IN_PROGRESS:
* - требуется подпись Ed25519 над строкой
* "AUTHORIFICATED:" + timeMs + authNonce
* (authNonce сохранён в ctx.authNonce после AuthChallenge).
*
* 3) Анонимный клиент (AUTH_STATUS_NONE или нет пользователя в ctx):
* - возвращается ошибка NOT_AUTHENTICATED.
*
* JSON:
* {
* "op": "ListSessions",
* "requestId": "...",
* "payload": {
* "timeMs": 1733310000000, // при AUTH_IN_PROGRESS
* "signatureB64": "base64-подпись" // при AUTH_IN_PROGRESS
* }
* }
* Новая логика (v2):
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
* - Пустой payload.
*/
public class Net_ListSessions_Request extends Net_Request {
/** Время на стороне клиента (мс с 1970-01-01). Используется при AUTH_IN_PROGRESS. */
private long timeMs;
/** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */
private String signatureB64;
public long getTimeMs() {
return timeMs;
}
public void setTimeMs(long timeMs) {
this.timeMs = timeMs;
}
public String getSignatureB64() {
return signatureB64;
}
public void setSignatureB64(String signatureB64) {
this.signatureB64 = signatureB64;
}
// пусто
}

View File

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

View File

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

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.*;
/**
* IT_02_Sessions
* IT_02_Sessions (v2)
*
* Цель:
* - проверить создание/листинг/refresh/close
* - проверить создание/листинг/вход-в-сессию(2 шага)/close
* - и после завершения оставить в БД 3 активных сессии (S1,S2,S3)
*
* Протокол v2:
* - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey)
* - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey))
* - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin)
*/
public class IT_02_Sessions {
@ -31,110 +36,65 @@ public class IT_02_Sessions {
}
public static String run() {
TestResult r = new TestResult("IT_02_Sessions");
TestResult r = new TestResult("IT_02_Sessions(v2)");
Duration t = Duration.ofSeconds(5);
String s1Id, s1Pwd;
String s2Id, s2Pwd;
String s3Id, s3Pwd;
Session s1, s2, s3;
try {
// 1) Создаём 3 сессии (каждая отдельным соединением, чтобы не зависеть от состояния WS)
Session s1 = createSession(LOGIN, t, r, "S1");
s1Id = s1.sessionId; s1Pwd = s1.sessionPwd;
// 1) Создаём 3 сессии (каждая отдельным соединением)
s1 = createSession(LOGIN, t, r, "S1");
s2 = createSession(LOGIN, t, r, "S2");
s3 = createSession(LOGIN, t, r, "S3");
Session s2 = createSession(LOGIN, t, r, "S2");
s2Id = s2.sessionId; s2Pwd = s2.sessionPwd;
Session s3 = createSession(LOGIN, t, r, "S3");
s3Id = s3.sessionId; s3Pwd = s3.sessionPwd;
// 2) ListSessions в AUTH_IN_PROGRESS должны быть S1,S2,S3
// 2) Входим в S1 (2 шага) и делаем ListSessions (AUTH_STATUS_USER) должны быть S1,S2,S3
try (WsSession ws = WsSession.open()) {
String nonceResp = ws.call("AuthChallenge(list)", JsonBuilders.authChallenge(LOGIN), t);
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(list) must be 200");
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce, "authNonce must not be null");
sessionLogin2Steps(ws, s1, t, "Login(S1)", r);
long timeMs = System.currentTimeMillis();
String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN));
String listResp = ws.call("ListSessions(AUTH_IN_PROGRESS)", JsonBuilders.listSessions(timeMs, sig), t);
assertEquals(200, JsonParsers.status(listResp), "ListSessions must be 200");
String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t);
assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200");
List<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(s2Id), "Must contain S2");
assertTrue(ids.contains(s3Id), "Must contain S3");
assertTrue(ids.contains(s1.sessionId), "Must contain S1");
assertTrue(ids.contains(s2.sessionId), "Must contain S2");
assertTrue(ids.contains(s3.sessionId), "Must contain S3");
r.ok("Проверка OK: список содержит S1,S2,S3");
}
// 3) RefreshSession(S1) -> после refresh в этом же соединении делаем ListSessions(AUTH_STATUS_USER) (timeMs=0)
try (WsSession ws = WsSession.open()) {
String refreshResp = ws.call("RefreshSession(S1)", JsonBuilders.refreshSession(s1Id, s1Pwd), t);
assertEquals(200, JsonParsers.status(refreshResp), "RefreshSession(S1) must be 200");
assertNotNull(JsonParsers.storagePwd(refreshResp), "storagePwd must not be null");
r.ok("RefreshSession(S1): OK");
String listInUserResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t);
assertEquals(200, JsonParsers.status(listInUserResp), "ListSessions(AUTH_STATUS_USER) must be 200");
List<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 остались.
// 3) Проверяем CloseActiveSession так, чтобы итогом всё равно осталось 3 сессии:
// создаём TEMP, логинимся в S1, закрываем TEMP, убеждаемся что S1,S2,S3 остались.
Session temp = createSession(LOGIN, t, r, "TEMP");
String tempId = temp.sessionId;
try (WsSession ws = WsSession.open()) {
String nonceResp = ws.call("AuthChallenge(close TEMP)", JsonBuilders.authChallenge(LOGIN), t);
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(close TEMP) must be 200");
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce);
sessionLogin2Steps(ws, s1, t, "Login(S1) for close", r);
long timeMs = System.currentTimeMillis();
String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN));
String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(tempId, timeMs, sig), t);
String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(temp.sessionId, 0L, ""), t);
assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200");
r.ok("CloseActiveSession(TEMP): OK");
}
// 5) Финальная проверка: снова ListSessions(AUTH_IN_PROGRESS) => S1,S2,S3 должны остаться, TEMP нет
// 4) Финальная проверка: снова логинимся в S1 и ListSessions => S1,S2,S3 должны остаться, TEMP нет
try (WsSession ws = WsSession.open()) {
String nonceResp = ws.call("AuthChallenge(final list)", JsonBuilders.authChallenge(LOGIN), t);
assertEquals(200, JsonParsers.status(nonceResp));
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce);
sessionLogin2Steps(ws, s1, t, "Final Login(S1)", r);
long timeMs = System.currentTimeMillis();
String sig = JsonBuilders.signAuthorificated(nonce, timeMs, TestConfig.getDevicePrivatKey(LOGIN));
String listResp = ws.call("ListSessions(final AUTH_IN_PROGRESS)", JsonBuilders.listSessions(timeMs, sig), t);
String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t);
assertEquals(200, JsonParsers.status(listResp));
List<String> ids = JsonParsers.sessionIds(listResp);
r.ok("Final ListSessions: " + ids);
assertTrue(ids.contains(s1Id));
assertTrue(ids.contains(s2Id));
assertTrue(ids.contains(s3Id));
assertFalse(ids.contains(tempId));
assertTrue(ids.contains(s1.sessionId));
assertTrue(ids.contains(s2.sessionId));
assertTrue(ids.contains(s3.sessionId));
assertFalse(ids.contains(temp.sessionId));
r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)");
}
} catch (Throwable e) {
r.fail("IT_02_Sessions упал: " + e.getMessage());
r.fail("IT_02_Sessions(v2) упал: " + e.getMessage());
}
return r.summaryLine();
@ -142,24 +102,56 @@ public class IT_02_Sessions {
private static Session createSession(String login, Duration t, TestResult r, String label) {
try (WsSession ws = WsSession.open()) {
// шаг 1: AuthChallenge
String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t);
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200");
String nonce = JsonParsers.authNonce(nonceResp);
assertNotNull(nonce, "authNonce must not be null for " + label);
String authNonce = JsonParsers.authNonce(nonceResp);
assertNotNull(authNonce, "authNonce must not be null for " + label);
String createResp = ws.call("CreateAuthSession(" + label + ")", JsonBuilders.createAuthSession(login, nonce, TestConfig.fakeStoragePwd()), t);
// для тестов: sessionKey = deviceKey (в реале будет отдельный keypair)
String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login);
// storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его)
String storagePwd = TestConfig.fakeStoragePwd();
// шаг 2: CreateAuthSession (device подпись + sessionPubKey)
String createResp = ws.call(
"CreateAuthSession(" + label + ")",
JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64),
t
);
assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200");
String sid = JsonParsers.sessionId(createResp);
String spw = JsonParsers.sessionPwd(createResp);
assertNotNull(sid, "sessionId must not be null");
assertNotNull(spw, "sessionPwd must not be null");
r.ok("Создана сессия " + label + ": sessionId=" + sid);
return new Session(sid, spw);
// для тестов используем devicePriv как sessionPriv
byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(login);
return new Session(sid, sessionPrivKey, storagePwd);
}
}
private record Session(String sessionId, String sessionPwd) {}
private static void sessionLogin2Steps(WsSession ws, Session s, Duration t, String label, TestResult r) {
// шаг 1: SessionChallenge(sessionId)
String chResp = ws.call("SessionChallenge " + label, JsonBuilders.sessionChallenge(s.sessionId), t);
assertEquals(200, JsonParsers.status(chResp), "SessionChallenge must be 200");
String nonce = JsonParsers.sessionNonce(chResp);
assertNotNull(nonce, "SessionChallenge nonce must not be null");
// шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...))
String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, nonce, s.sessionPrivKey), t);
assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200");
String storagePwd = JsonParsers.storagePwd(loginResp);
assertNotNull(storagePwd, "storagePwd must not be null after SessionLogin");
assertEquals(s.storagePwd, storagePwd, "storagePwd must match what client provided on CreateAuthSession");
r.ok(label + ": SessionLogin OK, storagePwd verified");
}
private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {}
}

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_03_AddBlock_NoAuth;
import test.it.cases.IT_04_UserParams_NoAuth;
import test.it.cases.IT_05_ListSubscribedChannels_200;
import test.it.utils.log.TestLog;
import java.util.ArrayList;
@ -31,7 +30,6 @@ public class IT_RunAllMain {
String s2 = IT_02_Sessions.run(); summaries.add(s2); if (s2.contains("FAIL:")) failed++;
String s3 = IT_03_AddBlock_NoAuth.run(); summaries.add(s3); if (s3.contains("FAIL:")) failed++;
String s4 = IT_04_UserParams_NoAuth.run(); summaries.add(s4); if (s4.contains("FAIL:")) failed++;
String s5 = IT_05_ListSubscribedChannels_200.run(); summaries.add(s5); if (s5.contains("FAIL:")) failed++;
TestLog.title("IT RUN RESULT (per test)");
for (String s : summaries) System.out.println(s);

View File

@ -58,9 +58,11 @@ public final class JsonBuilders {
""".formatted(requestId, login);
}
// ---------------- CreateAuthSession ----------------
// ---------------- CreateAuthSession (v2) ----------------
// v2: sessionKey генерируется на клиенте, на сервер отправляем только sessionPubKey (base64).
// подпись шага CreateAuthSession всё ещё делается deviceKey: "AUTHORIFICATED:" + timeMs + authNonce
public static String createAuthSession(String login, String authNonce, String storagePwd) {
public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionPubKeyB64) {
long timeMs = System.currentTimeMillis();
byte[] devicePriv = TestConfig.getDevicePrivatKey(login);
String sigB64 = signAuthorificated(authNonce, timeMs, devicePriv);
@ -72,12 +74,56 @@ public final class JsonBuilders {
"requestId": "%s",
"payload": {
"storagePwd": "%s",
"sessionPubKeyB64": "%s",
"timeMs": %d,
"signatureB64": "%s",
"clientInfo": "%s"
}
}
""".formatted(requestId, storagePwd, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO);
""".formatted(
requestId,
storagePwd,
sessionPubKeyB64,
timeMs,
sigB64,
TestConfig.TEST_CLIENT_INFO
);
}
// ---------------- SessionChallenge (v2) ----------------
public static String sessionChallenge(String sessionId) {
String requestId = TestIds.next("sch");
return """
{
"op": "SessionChallenge",
"requestId": "%s",
"payload": {
"sessionId": "%s"
}
}
""".formatted(requestId, sessionId);
}
// ---------------- SessionLogin (v2) ----------------
public static String sessionLogin(String sessionId, String nonce, byte[] sessionPrivKey) {
long timeMs = System.currentTimeMillis();
String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey);
String requestId = TestIds.next("slogin");
return """
{
"op": "SessionLogin",
"requestId": "%s",
"payload": {
"sessionId": "%s",
"timeMs": %d,
"signatureB64": "%s",
"clientInfo": "%s"
}
}
""".formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO);
}
// ---------------- ListSessions ----------------
@ -97,23 +143,6 @@ public final class JsonBuilders {
""".formatted(requestId, timeMs, signatureB64);
}
// ---------------- RefreshSession ----------------
public static String refreshSession(String sessionId, String sessionPwd) {
String requestId = TestIds.next("refresh");
return """
{
"op": "RefreshSession",
"requestId": "%s",
"payload": {
"sessionId": "%s",
"sessionPwd": "%s",
"clientInfo": "%s"
}
}
""".formatted(requestId, sessionId, sessionPwd, TestConfig.TEST_CLIENT_INFO);
}
// ---------------- CloseActiveSession ----------------
public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) {
@ -145,7 +174,6 @@ public final class JsonBuilders {
""".formatted(requestId, login);
}
/**
* Подпись для режима AUTH_IN_PROGRESS:
* preimage = "AUTHORIFICATED:" + timeMs + authNonce
@ -157,4 +185,16 @@ public final class JsonBuilders {
byte[] sig = Ed25519Util.sign(preimage, devicePrivKey);
return Base64.getEncoder().encodeToString(sig);
}
/**
* Подпись для SessionLogin(v2):
* preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce
* подписываем sessionPrivKey.
*/
public static String signSessionLogin(String sessionId, long timeMs, String nonce, byte[] sessionPrivKey) {
String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce;
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
byte[] sig = Ed25519Util.sign(preimage, sessionPrivKey);
return Base64.getEncoder().encodeToString(sig);
}
}

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