промежуточный не рабочий комит
This commit is contained in:
AidarKC 2025-12-10 16:15:36 +03:00
parent 95ec6ba037
commit 00fc9e3926
16 changed files with 506 additions and 146 deletions

2
.idea/vcs.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -31,12 +31,13 @@ dependencies {
implementation project(':shine-server-config') // модуль настроек из application.properties implementation project(':shine-server-config') // модуль настроек из application.properties
implementation project('shine-server-geo') // модуль для определения геолокации по IP
implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
implementation project(':shine-server-geo') // модуль для определения геолокации по IP
implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response
implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов

View File

@ -34,7 +34,6 @@ public class DatabaseInitializer {
Path dbFile = Paths.get(dbPath); Path dbFile = Paths.get(dbPath);
try { try {
// создаём директорию, если нужно
Path parent = dbFile.getParent(); Path parent = dbFile.getParent();
if (parent != null && !Files.exists(parent)) { if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent); Files.createDirectories(parent);
@ -75,18 +74,17 @@ public class DatabaseInitializer {
try (Connection conn = DriverManager.getConnection(jdbcUrl); try (Connection conn = DriverManager.getConnection(jdbcUrl);
Statement st = conn.createStatement()) { Statement st = conn.createStatement()) {
// включаем внешние ключи на этом соединении (для инициализации тоже)
st.execute("PRAGMA foreign_keys = ON"); st.execute("PRAGMA foreign_keys = ON");
// 1. Таблица solana_users // 1. solana_users
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS solana_users ( CREATE TABLE IF NOT EXISTS solana_users (
login TEXT NOT NULL, login TEXT NOT NULL,
loginId INTEGER NOT NULL PRIMARY KEY, loginId INTEGER NOT NULL PRIMARY KEY,
bchId INTEGER NOT NULL, bchId INTEGER NOT NULL,
loginKey TEXT, -- основной публичный ключ (логин) loginKey TEXT,
deviceKey TEXT, -- публичный ключ устройства deviceKey TEXT,
bchLimit INTEGER -- может быть NULL bchLimit INTEGER
); );
"""); """);
@ -95,8 +93,7 @@ public class DatabaseInitializer {
ON solana_users (login); ON solana_users (login);
"""); """);
// 2. Таблица active_sessions // 2. active_sessions
// sessionId TEXT (base64 от 32 байт).
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS active_sessions ( CREATE TABLE IF NOT EXISTS active_sessions (
sessionId TEXT NOT NULL PRIMARY KEY, sessionId TEXT NOT NULL PRIMARY KEY,
@ -108,6 +105,10 @@ public class DatabaseInitializer {
pushEndpoint TEXT, pushEndpoint TEXT,
pushP256dhKey TEXT, pushP256dhKey TEXT,
pushAuthKey TEXT, pushAuthKey TEXT,
clientIp TEXT,
clientInfoFromClient TEXT,
clientInfoFromRequest TEXT,
userLanguage TEXT,
FOREIGN KEY (loginId) REFERENCES solana_users(loginId) FOREIGN KEY (loginId) REFERENCES solana_users(loginId)
); );
"""); """);
@ -117,8 +118,7 @@ public class DatabaseInitializer {
ON active_sessions (loginId); ON active_sessions (loginId);
"""); """);
// 3. Таблица users_params // 3. users_params
// Пара (loginId, param) должна быть уникальна.
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS users_params ( CREATE TABLE IF NOT EXISTS users_params (
loginId INTEGER NOT NULL, loginId INTEGER NOT NULL,
@ -138,7 +138,7 @@ public class DatabaseInitializer {
ON users_params (loginId); ON users_params (loginId);
"""); """);
// 4. Таблица ip_geo_cache персистентный кэш геолокации по IP // 4. ip_geo_cache
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS ip_geo_cache ( CREATE TABLE IF NOT EXISTS ip_geo_cache (
ip TEXT NOT NULL PRIMARY KEY, ip TEXT NOT NULL PRIMARY KEY,

View File

@ -9,6 +9,25 @@ import java.sql.*;
* DAO для таблицы active_sessions. * DAO для таблицы active_sessions.
* *
* Здесь мы храним данные об активных сессиях пользователя (для wss-соединений). * Здесь мы храним данные об активных сессиях пользователя (для wss-соединений).
*
* Структура таблицы:
*
* CREATE TABLE active_sessions (
* sessionId TEXT NOT NULL PRIMARY KEY,
* loginId INTEGER NOT NULL,
* sessionPwd TEXT NOT NULL,
* storagePwd TEXT NOT NULL,
* sessionCreatedAtMs INTEGER NOT NULL,
* lastAuthirificatedAtMs INTEGER NOT NULL,
* pushEndpoint TEXT,
* pushP256dhKey TEXT,
* pushAuthKey TEXT,
* clientIp TEXT,
* clientInfoFromClient TEXT,
* clientInfoFromRequest TEXT,
* userLanguage TEXT,
* FOREIGN KEY (loginId) REFERENCES solana_users(loginId)
* );
*/ */
public final class ActiveSessionsDAO { public final class ActiveSessionsDAO {
@ -43,20 +62,28 @@ public final class ActiveSessionsDAO {
lastAuthirificatedAtMs, lastAuthirificatedAtMs,
pushEndpoint, pushEndpoint,
pushP256dhKey, pushP256dhKey,
pushAuthKey pushAuthKey,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) clientIp,
clientInfoFromClient,
clientInfoFromRequest,
userLanguage
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) { try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) {
ps.setString(1, session.getSessionId()); ps.setString(1, session.getSessionId());
ps.setLong(2, session.getLoginId()); ps.setLong(2, session.getLoginId());
ps.setString(3, session.getSessionPwd()); ps.setString(3, session.getSessionPwd());
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());
ps.setString(7, session.getPushEndpoint()); ps.setString(7, session.getPushEndpoint());
ps.setString(8, session.getPushP256dhKey()); ps.setString(8, session.getPushP256dhKey());
ps.setString(9, session.getPushAuthKey()); ps.setString(9, session.getPushAuthKey());
ps.setString(10, session.getClientIp());
ps.setString(11, session.getClientInfoFromClient());
ps.setString(12, session.getClientInfoFromRequest());
ps.setString(13, session.getUserLanguage());
ps.executeUpdate(); ps.executeUpdate();
} }
@ -76,7 +103,11 @@ public final class ActiveSessionsDAO {
lastAuthirificatedAtMs, lastAuthirificatedAtMs,
pushEndpoint, pushEndpoint,
pushP256dhKey, pushP256dhKey,
pushAuthKey pushAuthKey,
clientIp,
clientInfoFromClient,
clientInfoFromRequest,
userLanguage
FROM active_sessions FROM active_sessions
WHERE sessionId = ? WHERE sessionId = ?
"""; """;
@ -94,6 +125,7 @@ public final class ActiveSessionsDAO {
/** /**
* Обновить только lastAuthirificatedAtMs для конкретной сессии. * Обновить только lastAuthirificatedAtMs для конкретной сессии.
* (оставляю для совместимости, вдруг ещё где-то используется)
*/ */
public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException { public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException {
String sql = """ String sql = """
@ -109,6 +141,45 @@ public final class ActiveSessionsDAO {
} }
} }
/**
* Обновление метаданных при RefreshSession:
* - lastAuthirificatedAtMs
* - clientIp
* - clientInfoFromClient
* - clientInfoFromRequest
* - userLanguage
*/
public void updateOnRefresh(
String sessionId,
long lastAuthMs,
String clientIp,
String clientInfoFromClient,
String clientInfoFromRequest,
String userLanguage
) throws SQLException {
String sql = """
UPDATE active_sessions
SET
lastAuthirificatedAtMs = ?,
clientIp = ?,
clientInfoFromClient = ?,
clientInfoFromRequest = ?,
userLanguage = ?
WHERE sessionId = ?
""";
try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) {
ps.setLong(1, lastAuthMs);
ps.setString(2, clientIp);
ps.setString(3, clientInfoFromClient);
ps.setString(4, clientInfoFromRequest);
ps.setString(5, userLanguage);
ps.setString(6, sessionId);
ps.executeUpdate();
}
}
/** /**
* Удаление записи по sessionId. * Удаление записи по sessionId.
* Если записи нет просто ничего не удалит (0 строк). * Если записи нет просто ничего не удалит (0 строк).
@ -122,16 +193,23 @@ public final class ActiveSessionsDAO {
} }
} }
/**
* Маппинг ResultSet ActiveSession (все 13 полей).
*/
private ActiveSession mapRow(ResultSet rs) throws SQLException { private ActiveSession mapRow(ResultSet rs) throws SQLException {
String sessionId = rs.getString("sessionId"); String sessionId = rs.getString("sessionId");
long loginId = rs.getLong("loginId"); long loginId = rs.getLong("loginId");
String sessionPwd = rs.getString("sessionPwd"); String sessionPwd = rs.getString("sessionPwd");
String storagePwd = rs.getString("storagePwd"); String storagePwd = rs.getString("storagePwd");
long sessionCreatedAtMs = rs.getLong("sessionCreatedAtMs"); long sessionCreatedAtMs = rs.getLong("sessionCreatedAtMs");
long lastAuthirificatedAtMs = rs.getLong("lastAuthirificatedAtMs"); long lastAuthirificatedAtMs = rs.getLong("lastAuthirificatedAtMs");
String pushEndpoint = rs.getString("pushEndpoint"); String pushEndpoint = rs.getString("pushEndpoint");
String pushP256dhKey = rs.getString("pushP256dhKey"); String pushP256dhKey = rs.getString("pushP256dhKey");
String pushAuthKey = rs.getString("pushAuthKey"); String pushAuthKey = rs.getString("pushAuthKey");
String clientIp = rs.getString("clientIp");
String clientInfoFromClient = rs.getString("clientInfoFromClient");
String clientInfoFromRequest = rs.getString("clientInfoFromRequest");
String userLanguage = rs.getString("userLanguage");
return new ActiveSession( return new ActiveSession(
sessionId, sessionId,
@ -142,7 +220,11 @@ public final class ActiveSessionsDAO {
lastAuthirificatedAtMs, lastAuthirificatedAtMs,
pushEndpoint, pushEndpoint,
pushP256dhKey, pushP256dhKey,
pushAuthKey pushAuthKey,
clientIp,
clientInfoFromClient,
clientInfoFromRequest,
userLanguage
); );
} }
} }

View File

@ -3,8 +3,6 @@ package shine.db.entities;
/** /**
* Модель активной сессии (таблица active_sessions). * Модель активной сессии (таблица active_sessions).
* *
* Поля соответствуют схеме:
*
* CREATE TABLE active_sessions ( * CREATE TABLE active_sessions (
* sessionId TEXT NOT NULL PRIMARY KEY, * sessionId TEXT NOT NULL PRIMARY KEY,
* loginId INTEGER NOT NULL, * loginId INTEGER NOT NULL,
@ -15,6 +13,10 @@ package shine.db.entities;
* pushEndpoint TEXT, * pushEndpoint TEXT,
* pushP256dhKey TEXT, * pushP256dhKey TEXT,
* pushAuthKey TEXT, * pushAuthKey TEXT,
* clientIp TEXT,
* clientInfoFromClient TEXT,
* clientInfoFromRequest TEXT,
* userLanguage TEXT,
* FOREIGN KEY (loginId) REFERENCES solana_users(loginId) * FOREIGN KEY (loginId) REFERENCES solana_users(loginId)
* ); * );
*/ */
@ -30,6 +32,12 @@ public class ActiveSession {
private String pushP256dhKey; // TEXT (nullable) private String pushP256dhKey; // TEXT (nullable)
private String pushAuthKey; // TEXT (nullable) private String pushAuthKey; // TEXT (nullable)
// Новые поля
private String clientIp; // IP клиента при auth/refresh
private String clientInfoFromClient; // строка от клиента (PWA)
private String clientInfoFromRequest; // строка, собранная на сервере
private String userLanguage; // prefer-language (например, "ru-RU")
public ActiveSession() { public ActiveSession() {
} }
@ -41,7 +49,11 @@ public class ActiveSession {
long lastAuthirificatedAtMs, long lastAuthirificatedAtMs,
String pushEndpoint, String pushEndpoint,
String pushP256dhKey, String pushP256dhKey,
String pushAuthKey) { String pushAuthKey,
String clientIp,
String clientInfoFromClient,
String clientInfoFromRequest,
String userLanguage) {
this.sessionId = sessionId; this.sessionId = sessionId;
this.loginId = loginId; this.loginId = loginId;
this.sessionPwd = sessionPwd; this.sessionPwd = sessionPwd;
@ -51,6 +63,10 @@ public class ActiveSession {
this.pushEndpoint = pushEndpoint; this.pushEndpoint = pushEndpoint;
this.pushP256dhKey = pushP256dhKey; this.pushP256dhKey = pushP256dhKey;
this.pushAuthKey = pushAuthKey; this.pushAuthKey = pushAuthKey;
this.clientIp = clientIp;
this.clientInfoFromClient = clientInfoFromClient;
this.clientInfoFromRequest = clientInfoFromRequest;
this.userLanguage = userLanguage;
} }
// --- getters / setters --- // --- getters / setters ---
@ -126,4 +142,36 @@ public class ActiveSession {
public void setPushAuthKey(String pushAuthKey) { public void setPushAuthKey(String pushAuthKey) {
this.pushAuthKey = pushAuthKey; this.pushAuthKey = pushAuthKey;
} }
}
public String getClientIp() {
return clientIp;
}
public void setClientIp(String clientIp) {
this.clientIp = clientIp;
}
public String getClientInfoFromClient() {
return clientInfoFromClient;
}
public void setClientInfoFromClient(String clientInfoFromClient) {
this.clientInfoFromClient = clientInfoFromClient;
}
public String getClientInfoFromRequest() {
return clientInfoFromRequest;
}
public void setClientInfoFromRequest(String clientInfoFromRequest) {
this.clientInfoFromRequest = clientInfoFromRequest;
}
public String getUserLanguage() {
return userLanguage;
}
public void setUserLanguage(String userLanguage) {
this.userLanguage = userLanguage;
}
}

View File

@ -22,6 +22,9 @@ dependencies {
implementation 'org.eclipse.jetty:jetty-servlet:11.0.20' implementation 'org.eclipse.jetty:jetty-servlet:11.0.20'
implementation 'org.eclipse.jetty.websocket:websocket-jetty-server:11.0.20' implementation 'org.eclipse.jetty.websocket:websocket-jetty-server:11.0.20'
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
} }
java { java {

View File

@ -2,18 +2,25 @@ package shine.geo;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import shine.db.dao.IpGeoCacheDAO;
import shine.db.entities.IpGeoCacheEntry;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.sql.SQLException;
/** /**
* Сервис для геолокации по IP. * Сервис для геолокации по IP.
* . *
* Основной метод: * Основной метод без кэша:
* resolveCountryCityOrIp(ip) -> "Country, City" или GEO_UNKNOWN, если не удалось. * resolveCountryCityOrIp(ip) -> "Country, City" или GEO_UNKNOWN
*
* Метод с кэшированием в БД:
* resolveCountryCityOrIpWithCache(ip) -> сначала смотрит в ip_geo_cache,
* при отсутствии записи обращается к внешнему сервису, сохраняет результат в кэш и возвращает его.
*/ */
public final class GeoLookupService { public final class GeoLookupService {
@ -34,6 +41,8 @@ public final class GeoLookupService {
} }
/** /**
* ВАРИАНТ БЕЗ КЭША.
*
* Возвращает строку вида "Country, City" по IP. * Возвращает строку вида "Country, City" по IP.
* Если запрос не удался, возвращает GEO_UNKNOWN. * Если запрос не удался, возвращает GEO_UNKNOWN.
*/ */
@ -79,7 +88,6 @@ public final class GeoLookupService {
return GEO_UNKNOWN; return GEO_UNKNOWN;
} }
// Собираем строку
if (country != null && city != null) { if (country != null && city != null) {
return country + ", " + city; return country + ", " + city;
} else if (country != null) { } else if (country != null) {
@ -94,6 +102,64 @@ public final class GeoLookupService {
} }
} }
/**
* ВАРИАНТ С КЭШЕМ В БАЗЕ (ip_geo_cache).
*
* Логика:
* 1) Если IP пустой или локальный сразу GEO_UNKNOWN (и ничего не пишем в кэш).
* 2) Пытаемся найти ip в ip_geo_cache:
* - если нашли возвращаем geo из записи.
* 3) Если не нашли вызываем resolveCountryCityOrIp(ip) (внешний сервис),
* - результат (включая GEO_UNKNOWN) сохраняем в ip_geo_cache через IpGeoCacheDAO.upsert()
* - возвращаем сохранённый результат.
*
* В случае ошибок БД просто падаем назад на поведение без кэша.
*/
public static String resolveCountryCityOrIpWithCache(String ip) {
if (ip == null || ip.isBlank()) {
return GEO_UNKNOWN;
}
// Приватные/локальные IP не кешируем и не запрашиваем
if (isPrivateOrLocalIp(ip)) {
return GEO_UNKNOWN;
}
// 1. Сначала пробуем взять из кэша
IpGeoCacheDAO dao = IpGeoCacheDAO.getInstance();
try {
IpGeoCacheEntry cached = dao.getByIp(ip);
if (cached != null) {
String geo = cached.getGeo();
if (geo != null && !geo.isBlank()) {
return geo;
}
// Если geo пустая строка (на всякий случай) идём за свежими данными.
}
} catch (SQLException e) {
// Ошибка БД логируем при желании и продолжаем без кэша
// log.warn("Failed to read IP geo cache", e);
}
// 2. Вызываем "сырой" метод, который ходит во внешний сервис
String resolvedGeo = resolveCountryCityOrIp(ip);
// 3. Пишем результат в кэш (включая GEO_UNKNOWN)
try {
IpGeoCacheEntry entry = new IpGeoCacheEntry(
ip,
resolvedGeo,
System.currentTimeMillis()
);
dao.upsert(entry);
} catch (SQLException e) {
// Ошибка БД при записи просто игнорируем, кэш не обязателен для работы
// log.warn("Failed to upsert IP geo cache", e);
}
return resolvedGeo;
}
/** /**
* Пытается получить внешний IP текущей машины через HTTP-сервис. * Пытается получить внешний IP текущей машины через HTTP-сервис.
* В случае ошибки возвращает fallbackIp. * В случае ошибки возвращает fallbackIp.

View File

@ -29,6 +29,8 @@ dependencies {
implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
implementation project(':shine-server-geo') // модуль для определения геолокации по IP
} }

View File

@ -5,8 +5,9 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
/** /**
* Ответ на AuthChallenge. * Ответ на AuthChallenge.
* *
* При успехе сервер возвращает временный секрет sessionPwd, * При успехе сервер возвращает одноразовый nonce для подписи (authNonce),
* который клиент обязан использовать на втором шаге при формировании подписи. * который клиент обязан использовать на втором шаге при формировании строки
* для цифровой подписи.
* *
* JSON: * JSON:
* { * {
@ -14,22 +15,23 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
* "requestId": "...", * "requestId": "...",
* "status": 200, * "status": 200,
* "payload": { * "payload": {
* "sessionPwd": "base64-строка-от-32-байт" * "authNonce": "base64-строка-от-32-байт"
* } * }
* } * }
*/ */
public class Net_AuthChallenge_Response extends NetResponse { public class Net_AuthChallenge_Response extends NetResponse {
/** /**
* Временный секрет, сгенерированный сервером. * Одноразовый nonce для авторификации.
* Строка это base64-представление 32 случайных байт. * Строка это base64-представление 32 случайных байт.
*/ */
private String sessionPwd; private String authNonce;
public String getSessionPwd() { public String getAuthNonce() {
return sessionPwd; return authNonce;
} }
public void setSessionPwd(String sessionPwd) {
this.sessionPwd = sessionPwd; public void setAuthNonce(String authNonce) {
this.authNonce = authNonce;
} }
} }

View File

@ -6,13 +6,16 @@ import server.logic.ws_protocol.JSON.entyties.NetRequest;
* Шаг 2 авторизации: подтверждение владения ключом и установка сессии. * Шаг 2 авторизации: подтверждение владения ключом и установка сессии.
* *
* Клиент: * Клиент:
* 1) получает от сервера sessionPwd на шаге 1; * 1) получает от сервера authNonce на шаге 1;
* 2) генерирует свой StoragePwd (base64 от 32 байт); * 2) генерирует свой StoragePwd (base64 от 32 байт);
* 3) формирует строку для подписи: * 3) формирует строку для подписи:
* "AUTHORIFICATED:" + timeMs + sessionPwd * "AUTHORIFICATED:" + timeMs + authNonce
* 4) подписывает эту строку своим приватным ключом (pubkey1), * 4) подписывает эту строку своим приватным ключом (pubkey1),
* отправляет подпись и StoragePwd на сервер. * отправляет подпись и StoragePwd на сервер.
* *
* Дополнительно:
* - clientInfo короткая строка (до 50 символов) с данными об устройстве/клиенте.
*
* Формат входящего JSON: * Формат входящего JSON:
* { * {
* "op": "CreateAuthSession", * "op": "CreateAuthSession",
@ -20,12 +23,10 @@ import server.logic.ws_protocol.JSON.entyties.NetRequest;
* "payload": { * "payload": {
* "storagePwd": "base64-строка-от-32-байт", * "storagePwd": "base64-строка-от-32-байт",
* "timeMs": 1733310000000, * "timeMs": 1733310000000,
* "signatureB64": "base64-подпись-Ed25519" * "signatureB64": "base64-подпись-Ed25519",
* "clientInfo": "Chrome/Android" // опционально, до 50 символов
* } * }
* } * }
*
* При успешной проверке подписи сервер создаёт запись в active_sessions
* и возвращает sessionId (base64-строка от 32 байт).
*/ */
public class Net_CreateAuthSession_Request extends NetRequest { public class Net_CreateAuthSession_Request extends NetRequest {
@ -35,9 +36,12 @@ public class Net_CreateAuthSession_Request extends NetRequest {
/** Время на стороне клиента (мс с 1970-01-01). */ /** Время на стороне клиента (мс с 1970-01-01). */
private long timeMs; private long timeMs;
/** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + sessionPwd (base64). */ /** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64). */
private String signatureB64; private String signatureB64;
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
private String clientInfo;
public String getStoragePwd() { public String getStoragePwd() {
return storagePwd; return storagePwd;
} }
@ -61,4 +65,12 @@ public class Net_CreateAuthSession_Request extends NetRequest {
public void setSignatureB64(String signatureB64) { public void setSignatureB64(String signatureB64) {
this.signatureB64 = signatureB64; this.signatureB64 = signatureB64;
} }
}
public String getClientInfo() {
return clientInfo;
}
public void setClientInfo(String clientInfo) {
this.clientInfo = clientInfo;
}
}

View File

@ -6,7 +6,7 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
* Ответ на CreateAuthSession. * Ответ на CreateAuthSession.
* *
* При успехе сервер создаёт запись в active_sessions * При успехе сервер создаёт запись в active_sessions
* и возвращает идентификатор сессии sessionId. * и возвращает идентификатор сессии sessionId и секрет сессии sessionPwd.
* *
* JSON: * JSON:
* { * {
@ -14,7 +14,8 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
* "requestId": "...", * "requestId": "...",
* "status": 200, * "status": 200,
* "payload": { * "payload": {
* "sessionId": "base64-строка-от-32-байт" * "sessionId": "base64-строка-от-32-байт",
* "sessionPwd": "base64-строка-от-32-байт"
* } * }
* } * }
*/ */
@ -23,6 +24,9 @@ public class Net_CreateAuthSession_Response extends NetResponse {
/** Идентификатор сессии, base64 от 32 байт. */ /** Идентификатор сессии, base64 от 32 байт. */
private String sessionId; private String sessionId;
/** Секрет сессии, base64 от 32 байт. */
private String sessionPwd;
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
} }
@ -30,4 +34,12 @@ public class Net_CreateAuthSession_Response extends NetResponse {
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

@ -11,7 +11,8 @@ import server.logic.ws_protocol.JSON.entyties.NetRequest;
* JSON (payload): * JSON (payload):
* { * {
* "sessionId": "base64-id-сессии", * "sessionId": "base64-id-сессии",
* "sessionPwd": "base64-sessionPwd" * "sessionPwd": "base64-sessionPwd",
* "clientInfo": "до 50 символов, краткая строка об устройстве"
* } * }
*/ */
public class Net_RefreshSession_Request extends NetRequest { public class Net_RefreshSession_Request extends NetRequest {
@ -19,6 +20,12 @@ public class Net_RefreshSession_Request extends NetRequest {
private String sessionId; private String sessionId;
private String sessionPwd; private String sessionPwd;
/**
* Краткая строка с информацией об устройстве/клиенте, до 50 символов.
* Например: "PWA/Chrome/Android".
*/
private String clientInfo;
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
} }
@ -34,4 +41,12 @@ public class Net_RefreshSession_Request extends NetRequest {
public void setSessionPwd(String sessionPwd) { public void setSessionPwd(String sessionPwd) {
this.sessionPwd = sessionPwd; this.sessionPwd = sessionPwd;
} }
}
public String getClientInfo() {
return clientInfo;
}
public void setClientInfo(String clientInfo) {
this.clientInfo = clientInfo;
}
}

View File

@ -46,7 +46,6 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
SolanaUser solanaUser = SolanaUsersDAO.getInstance().getByLogin(login); SolanaUser solanaUser = SolanaUsersDAO.getInstance().getByLogin(login);
if (solanaUser == null) { if (solanaUser == null) {
// TODO позже запрос в Solana, если не нашли локально
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.UNVERIFIED, WireCodes.Status.UNVERIFIED,
@ -55,23 +54,24 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
); );
} }
// 3) Заполняем контекст целиком пользователем // 3) Заполняем контекст пользователем
ctx.setSolanaUser(solanaUser); ctx.setSolanaUser(solanaUser);
// 4) Генерируем надёжный sessionPwd = base64(32 случайных байт) // 4) Генерируем одноразовый authNonce = base64(32 случайных байт)
byte[] buf = new byte[32]; byte[] buf = new byte[32];
RANDOM.nextBytes(buf); RANDOM.nextBytes(buf);
String sessionPwd = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
ctx.setSessionPwd(sessionPwd); // Используем поле sessionPwd в контексте как хранилище challenge (authNonce) до шага 2
ctx.setSessionPwd(authNonce);
// 5) Формируем ответ // 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());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setSessionPwd(sessionPwd); resp.setAuthNonce(authNonce);
return resp; return resp;
} }
} }

View File

@ -14,8 +14,13 @@ import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSession; import shine.db.entities.ActiveSession;
import shine.db.entities.SolanaUser; import shine.db.entities.SolanaUser;
import shine.geo.ClientInfoService;
import utils.crypto.Ed25519Util; import utils.crypto.Ed25519Util;
import org.eclipse.jetty.websocket.api.Session;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.sql.SQLException; import java.sql.SQLException;
import java.security.SecureRandom; import java.security.SecureRandom;
@ -28,20 +33,18 @@ import java.util.Base64;
* - storagePwd (base64 от 32 байт) * - storagePwd (base64 от 32 байт)
* - timeMs (long, мс с 1970-01-01) * - timeMs (long, мс с 1970-01-01)
* - signatureB64 (подпись Ed25519 над строкой: * - signatureB64 (подпись Ed25519 над строкой:
* "AUTHORIFICATED:" + timeMs + sessionPwd) * "AUTHORIFICATED:" + timeMs + authNonce)
* - clientInfo (опционально, до 50 символов)
* *
* Параметр sessionPwd клиент получил на шаге 1. * authNonce клиент получил на шаге 1 (AuthChallenge).
* Для проверки подписи используется pubkey1 (второй публичный ключ пользователя).
*
* Дополнительно:
* - timeMs должен отличаться от текущего времени сервера не более чем на 30 секунд.
* *
* При успехе: * При успехе:
* - создаётся запись ActiveSession в БД; * - создаётся запись ActiveSession в БД;
* - генерируется sessionId (base64 от 32 случайных байт); * - генерируется sessionId (base64 от 32 случайных байт);
* - генерируется sessionPwd (base64 от 32 случайных байт);
* - sessionCreatedAtMs и lastAuthirificatedAtMs = текущее время; * - sessionCreatedAtMs и lastAuthirificatedAtMs = текущее время;
* - pushEndpoint / pushP256dhKey / pushAuthKey остаются пустыми; * - заполняются поля clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage;
* - возвращается sessionId в ответе. * - возвращается sessionId и sessionPwd в ответе.
*/ */
public class Net_CreateAuthSession__Handler implements JsonMessageHandler { public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
@ -107,7 +110,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
long timeMs = req.getTimeMs(); long timeMs = req.getTimeMs();
long nowMs = System.currentTimeMillis(); long nowMs = System.currentTimeMillis();
// Проверка, что время клиента не отличается от времени сервера больше чем на 30 секунд
long diff = Math.abs(nowMs - timeMs); long diff = Math.abs(nowMs - timeMs);
if (diff > ALLOWED_SKEW_MS) { if (diff > ALLOWED_SKEW_MS) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
@ -118,6 +120,12 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
); );
} }
// Короткая строка clientInfo от клиента (до 50 символов)
String clientInfoFromClient = req.getClientInfo();
if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) {
clientInfoFromClient = clientInfoFromClient.substring(0, 50);
}
// --- выбираем публичный ключ pubkey1 --- // --- выбираем публичный ключ pubkey1 ---
String pubKeyB64 = user.getDeviceKey(); String pubKeyB64 = user.getDeviceKey();
if (pubKeyB64 == null || pubKeyB64.isBlank()) { if (pubKeyB64 == null || pubKeyB64.isBlank()) {
@ -143,8 +151,11 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
); );
} }
// --- собираем строку для подписи: "AUTHORIFICATED:" + timeMs + sessionPwd --- // --- authNonce (challenge) мы сохранили в ctx.sessionPwd на шаге 1 ---
String preimageStr = "AUTHORIFICATED:" + timeMs + ctx.getSessionPwd(); String authNonce = ctx.getSessionPwd();
// --- собираем строку для подписи: "AUTHORIFICATED:" + timeMs + authNonce ---
String preimageStr = "AUTHORIFICATED:" + timeMs + authNonce;
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
boolean sigOk = Ed25519Util.verify(preimage, signature64, publicKey32); boolean sigOk = Ed25519Util.verify(preimage, signature64, publicKey32);
@ -157,25 +168,47 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
); );
} }
// --- создаём уникальный sessionId (base64 от 32 байт) и записываем в БД --- // --- Генерируем настоящий секрет сессии (sessionPwd) и sessionId ---
String newSessionPwd = generateRandomSecret();
String sessionId = generateRandomSessionId();
long now = System.currentTimeMillis();
// --- Сбор данных о клиенте (IP, UA, язык) ---
Session wsSession = ctx.getWsSession();
String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession);
String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession);
String clientIp = "";
if (wsSession != null) {
SocketAddress rawAddr = wsSession.getRemoteAddress();
if (rawAddr instanceof InetSocketAddress inet) {
if (inet.getAddress() != null) {
clientIp = inet.getAddress().getHostAddress();
}
}
}
// TODO и сдесь тоже переписываем получение ИП адреса на стандартный метод и тоже дёргаем запрос геолокации который никуда не сохраняем просто что бы он в кэш сервера попал
// --- создаём запись ActiveSession и сохраняем в БД ---
ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance();
String sessionId;
ActiveSession activeSession; ActiveSession activeSession;
try { try {
sessionId = generateRandomSessionId();
long now = System.currentTimeMillis();
activeSession = new ActiveSession( activeSession = new ActiveSession(
sessionId, sessionId,
loginId, loginId,
ctx.getSessionPwd(), newSessionPwd, // настоящий секрет сессии
storagePwd, storagePwd,
now, now,
now, now,
null, // pushEndpoint null, // pushEndpoint
null, // pushP256dhKey null, // pushP256dhKey
null // pushAuthKey null, // pushAuthKey
clientIp,
clientInfoFromClient,
clientInfoFromRequest,
userLanguage
); );
dao.insert(activeSession); dao.insert(activeSession);
@ -192,9 +225,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
// --- обновляем контекст --- // --- обновляем контекст ---
ctx.setActiveSession(activeSession); ctx.setActiveSession(activeSession);
ctx.setSessionId(sessionId); ctx.setSessionId(sessionId);
ctx.setSessionPwd(newSessionPwd); // теперь в контексте хранится секрет сессии, а не authNonce
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
// Регистрируем это подключение в глобальном реестре активных соединений
ActiveConnectionsRegistry.getInstance().register(ctx); ActiveConnectionsRegistry.getInstance().register(ctx);
// --- формируем ответ --- // --- формируем ответ ---
@ -202,7 +235,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
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);
resp.setSessionId(sessionId); // попадёт в payload.sessionId resp.setSessionId(sessionId);
resp.setSessionPwd(newSessionPwd);
return resp; return resp;
} }
@ -214,4 +248,13 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
RANDOM.nextBytes(buf); RANDOM.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(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);
}
}

View File

@ -15,6 +15,7 @@ import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO; import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSession; import shine.db.entities.ActiveSession;
import shine.db.entities.SolanaUser; import shine.db.entities.SolanaUser;
import shine.geo.ClientInfoService;
import java.sql.SQLException; import java.sql.SQLException;
@ -24,19 +25,23 @@ import java.sql.SQLException;
* При успешной проверке sessionId + sessionPwd: * При успешной проверке sessionId + sessionPwd:
* - подтягивает пользователя по loginId из сессии; * - подтягивает пользователя по loginId из сессии;
* - заполняет ConnectionContext; * - заполняет ConnectionContext;
* - обновляет lastAuthirificatedAtMs в БД на текущее время; * - обновляет lastAuthirificatedAtMs и метаданные сессии в БД;
* - возвращает storagePwd в payload. * - возвращает storagePwd в payload.
*/ */
public class Net_RefreshSession_Handler implements JsonMessageHandler { public class Net_RefreshSession_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_RefreshSession_Handler.class); private static final Logger log = LoggerFactory.getLogger(Net_RefreshSession_Handler.class);
// максимум 50 символов для clientInfo от клиента
private static final int CLIENT_INFO_MAX_LEN = 50;
@Override @Override
public NetResponse handle(NetRequest request, ConnectionContext ctx) throws Exception { public NetResponse handle(NetRequest request, ConnectionContext ctx) throws Exception {
Net_RefreshSession_Request req = (Net_RefreshSession_Request) request; Net_RefreshSession_Request req = (Net_RefreshSession_Request) request;
String sessionId = req.getSessionId(); String sessionId = req.getSessionId();
String sessionPwd = req.getSessionPwd(); String sessionPwd = req.getSessionPwd();
String clientInfoFromClient = trimClientInfo(req.getClientInfo());
if (sessionId == null || sessionId.isBlank()) { if (sessionId == null || sessionId.isBlank()) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
@ -89,7 +94,7 @@ public class Net_RefreshSession_Handler implements JsonMessageHandler {
); );
} }
// --- достаём пользователя по loginId из сессии --- // --- вытаскиваем пользователя по loginId ---
SolanaUser solanaUser = null; SolanaUser solanaUser = null;
long loginId = session.getLoginId(); long loginId = session.getLoginId();
try { try {
@ -114,7 +119,43 @@ public class Net_RefreshSession_Handler implements JsonMessageHandler {
); );
} }
// Всё хорошо обновляем контекст соединения // --- собираем данные о клиенте из WebSocket-запроса ---
String clientIp = null;
String clientInfoFromRequest = null;
String userLanguage = null;
if (ctx != null && ctx.getWsSession() != null) {
clientIp = "8.8.8.8"; //TODO сделать нормальное получение ип адреса
// и сделать запрос геолокации и никуда его не сохранять запрос нужен просто что бы в кэш данные добавилиь если нужно
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) { if (ctx != null) {
ctx.setActiveSession(session); ctx.setActiveSession(session);
ctx.setSolanaUser(solanaUser); ctx.setSolanaUser(solanaUser);
@ -126,15 +167,7 @@ public class Net_RefreshSession_Handler implements JsonMessageHandler {
ActiveConnectionsRegistry.getInstance().register(ctx); ActiveConnectionsRegistry.getInstance().register(ctx);
} }
// Обновляем lastAuthirificatedAtMs в БД // --- ответ OK + storagePwd ---
try {
long nowMs = System.currentTimeMillis();
sessionsDao.updateLastAuthirificatedAtMs(sessionId, nowMs);
} catch (SQLException e) {
log.error("Ошибка БД при обновлении lastAuthirificatedAtMs для sessionId={}", sessionId, e);
}
// Возвращаем OK + storagePwd
Net_RefreshSession_Response resp = new Net_RefreshSession_Response(); Net_RefreshSession_Response resp = new Net_RefreshSession_Response();
resp.setOp(req.getOp()); resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
@ -142,4 +175,13 @@ public class Net_RefreshSession_Handler implements JsonMessageHandler {
resp.setStoragePwd(session.getStoragePwd()); resp.setStoragePwd(session.getStoragePwd());
return resp; 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

@ -20,10 +20,13 @@ import java.util.concurrent.CountDownLatch;
* 1) AddUser добавляем пользователя в локальную БД * 1) AddUser добавляем пользователя в локальную БД
* (loginKey и deviceKey разные). * (loginKey и deviceKey разные).
* *
* 2) AuthChallenge запрашиваем sessionPwd. * 2) AuthChallenge запрашиваем одноразовый authNonce
* для подписи шаге 2.
* *
* 3) CreateAuthSession подтверждаем владение deviceKey, * 3) CreateAuthSession подтверждаем владение deviceKey,
* создаётся сессия, сервер возвращает sessionId (строка). * создаётся сессия, сервер возвращает:
* - sessionId (строка, base64-32 байта)
* - sessionPwd (секрет сессии, base64-32 байта)
* *
* 4) Новое подключение: * 4) Новое подключение:
* - отправляем RefreshSession с тем же sessionId, * - отправляем RefreshSession с тем же sessionId,
@ -48,6 +51,9 @@ public class Test_AddUser_and_Authorification {
private static final long TEST_BCH_ID = 4222L; private static final long TEST_BCH_ID = 4222L;
private static final int TEST_BCH_LIMIT = 1_000_000; private static final int TEST_BCH_LIMIT = 1_000_000;
// Краткая строка clientInfo, которую клиент шлёт на шаге CreateAuthSession
private static final String TEST_CLIENT_INFO = "JavaTestClient/1.0";
// --- Тестовые пары ключей --- // --- Тестовые пары ключей ---
// loginKey ключ аккаунта (например, "основной") // loginKey ключ аккаунта (например, "основной")
// deviceKey ключ устройства, которым подписываем авторизацию // deviceKey ключ устройства, которым подписываем авторизацию
@ -72,12 +78,15 @@ public class Test_AddUser_and_Authorification {
// --- Глобальные переменные между сценариями --- // --- Глобальные переменные между сценариями ---
/** sessionPwd, выданный на шаге AuthChallenge. */ /** authNonce, выданный на шаге AuthChallenge. */
private static String GLOBAL_SESSION_PWD; private static String GLOBAL_AUTH_NONCE;
/** sessionId (строка, base64-32 байта), выданный на шаге CreateAuthSession. */ /** sessionId (строка, base64-32 байта), выданный на шаге CreateAuthSession. */
private static String GLOBAL_SESSION_ID; private static String GLOBAL_SESSION_ID;
/** sessionPwd (секрет сессии), выданный на шаге CreateAuthSession. */
private static String GLOBAL_SESSION_PWD;
/** storagePwd, который мы отправили при CreateAuthSession (для информации). */ /** storagePwd, который мы отправили при CreateAuthSession (для информации). */
private static String GLOBAL_STORAGE_PWD_SENT; private static String GLOBAL_STORAGE_PWD_SENT;
@ -107,7 +116,7 @@ public class Test_AddUser_and_Authorification {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
HttpClient client = HttpClient.newHttpClient(); HttpClient client = HttpClient.newHttpClient();
WebSocket ws = client.newWebSocketBuilder() client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), new Listener() { .buildAsync(URI.create(WS_URI), new Listener() {
private int step = 0; // 0 - AddUser, 1 - AuthStep1, 2 - AuthStep2 private int step = 0; // 0 - AddUser, 1 - AuthStep1, 2 - AuthStep2
@ -138,7 +147,7 @@ public class Test_AddUser_and_Authorification {
} }
case 2 -> { case 2 -> {
GLOBAL_STORAGE_PWD_SENT = generateFakeStoragePwd(); GLOBAL_STORAGE_PWD_SENT = generateFakeStoragePwd();
String json = buildAuthStep2Json(GLOBAL_SESSION_PWD, GLOBAL_STORAGE_PWD_SENT); String json = buildAuthStep2Json(GLOBAL_AUTH_NONCE, GLOBAL_STORAGE_PWD_SENT);
System.out.println(); System.out.println();
System.out.println("📤 [S1 / Шаг 3] Отправляем CreateAuthSession (подпись deviceKey):"); System.out.println("📤 [S1 / Шаг 3] Отправляем CreateAuthSession (подпись deviceKey):");
System.out.println(json); System.out.println(json);
@ -160,17 +169,19 @@ public class Test_AddUser_and_Authorification {
System.out.println(message); System.out.println(message);
System.out.println("-----------------------------------------------------"); System.out.println("-----------------------------------------------------");
// Шаг 2: получаем sessionPwd // Шаг 2: получаем authNonce
if (step == 1) { if (step == 1) {
GLOBAL_SESSION_PWD = extractSessionPwd(message); GLOBAL_AUTH_NONCE = extractAuthNonce(message);
System.out.println("🔑 [S1] Извлечён sessionPwd: " + GLOBAL_SESSION_PWD); System.out.println("🔑 [S1] Извлечён authNonce: " + GLOBAL_AUTH_NONCE);
} }
// Шаг 3: получаем sessionId // Шаг 3: получаем sessionId и sessionPwd
if (step == 2) { if (step == 2) {
GLOBAL_SESSION_ID = extractSessionId(message); GLOBAL_SESSION_ID = extractSessionId(message);
GLOBAL_SESSION_PWD = extractSessionPwd(message);
System.out.println("🆔 [S1] Извлечён sessionId: " + GLOBAL_SESSION_ID); System.out.println("🆔 [S1] Извлечён sessionId: " + GLOBAL_SESSION_ID);
System.out.println(" (Эта sessionId и sessionPwd понадобятся в сценариях 2 и 3)"); System.out.println("🔐 [S1] Извлечён sessionPwd: " + GLOBAL_SESSION_PWD);
System.out.println(" (Эти sessionId и sessionPwd понадобятся в сценариях 2 и 3)");
} }
step++; step++;
@ -221,7 +232,7 @@ public class Test_AddUser_and_Authorification {
// Специально подменяем пароль, чтобы сервер его НЕ принял // Специально подменяем пароль, чтобы сервер его НЕ принял
String wrongPwd = GLOBAL_SESSION_PWD + "_WRONG"; String wrongPwd = GLOBAL_SESSION_PWD + "_WRONG";
WebSocket ws = client.newWebSocketBuilder() client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), new Listener() { .buildAsync(URI.create(WS_URI), new Listener() {
@Override @Override
@ -281,7 +292,7 @@ public class Test_AddUser_and_Authorification {
System.out.println(); System.out.println();
System.out.println("=== СЦЕНАРИЙ 3: RefreshSession с КОРРЕКТНЫМ sessionPwd ==="); System.out.println("=== СЦЕНАРИЙ 3: RefreshSession с КОРРЕКТНЫМ sessionPwd ===");
System.out.println("Ожидаем УСПЕШНЫЙ ответ сервера (status=200),"); System.out.println("Ожидаем УСПЕШНЫЙ ответ сервера (status=200),");
System.out.println(" а в payload должен вернуться актуальный storagePwd (по твоей схеме)."); System.out.println(" а в payload должен вернуться актуальный storagePwd.");
if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) { if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) {
System.out.println("⚠️ Нет sessionId или sessionPwd из сценария 1, пропускаем сценарий 3."); System.out.println("⚠️ Нет sessionId или sessionPwd из сценария 1, пропускаем сценарий 3.");
@ -291,7 +302,7 @@ public class Test_AddUser_and_Authorification {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
HttpClient client = HttpClient.newHttpClient(); HttpClient client = HttpClient.newHttpClient();
WebSocket ws = client.newWebSocketBuilder() client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), new Listener() { .buildAsync(URI.create(WS_URI), new Listener() {
@Override @Override
@ -318,7 +329,7 @@ public class Test_AddUser_and_Authorification {
System.out.println("💬 [S3] Если status=200 — сессия успешно восстановлена."); System.out.println("💬 [S3] Если status=200 — сессия успешно восстановлена.");
String storagePwdFromServer = extractStoragePwd(message); String storagePwdFromServer = extractStoragePwd(message);
System.out.println("🧾 [S3] storagePwd от сервера: " + storagePwdFromServer); System.out.println("🧾 [S3] storagePwd от сервера: " + storagePwdFromServer);
System.out.println(" (Может совпадать с тем, что был в шаге 2, или быть обновлённым — зависит от логики сервера)"); System.out.println(" (Должен совпадать с тем, что отправляли в шаге 3 сценария 1)");
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario3 done"); webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario3 done");
webSocket.request(1); webSocket.request(1);
@ -375,7 +386,7 @@ public class Test_AddUser_and_Authorification {
); );
} }
// 2) Шаг 1 авторизации: запрос sessionPwd // 2) Шаг 1 авторизации: запрос authNonce
private static String buildAuthStep1Json() { private static String buildAuthStep1Json() {
return """ return """
{ {
@ -388,11 +399,15 @@ public class Test_AddUser_and_Authorification {
""".formatted(TEST_LOGIN); """.formatted(TEST_LOGIN);
} }
// 3) Шаг 2 авторизации: подтверждение подписью /**
// payload: storagePwd, timeMs, signatureB64 * 3) Шаг 2 авторизации: подтверждение подписью.
private static String buildAuthStep2Json(String sessionPwd, String storagePwd) { *
if (sessionPwd == null) { * @param authNonce одноразовый nonce с шага 1
sessionPwd = ""; * @param storagePwd клиентский storagePwd
*/
private static String buildAuthStep2Json(String authNonce, String storagePwd) {
if (authNonce == null) {
authNonce = "";
} }
if (storagePwd == null || storagePwd.isBlank()) { if (storagePwd == null || storagePwd.isBlank()) {
storagePwd = generateFakeStoragePwd(); storagePwd = generateFakeStoragePwd();
@ -400,8 +415,8 @@ public class Test_AddUser_and_Authorification {
long timeMs = System.currentTimeMillis(); long timeMs = System.currentTimeMillis();
// preimage = "AUTHORIFICATED:" + timeMs + sessionPwd // preimage = "AUTHORIFICATED:" + timeMs + authNonce
String preimageStr = "AUTHORIFICATED:" + timeMs + sessionPwd; String preimageStr = "AUTHORIFICATED:" + timeMs + authNonce;
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
// Подписываем приватным ключом устройства (deviceKey) // Подписываем приватным ключом устройства (deviceKey)
@ -415,31 +430,35 @@ public class Test_AddUser_and_Authorification {
"payload": { "payload": {
"storagePwd": "%s", "storagePwd": "%s",
"timeMs": %d, "timeMs": %d,
"signatureB64": "%s" "signatureB64": "%s",
"clientInfo": "%s"
} }
} }
""".formatted( """.formatted(
storagePwd, storagePwd,
timeMs, timeMs,
sigB64 sigB64,
TEST_CLIENT_INFO
); );
} }
// 4) RefreshSession: всё в payload // 4) RefreshSession: всё в payload
private static String buildRefreshSessionJson(String sessionId, String sessionPwd, String requestId) { private static String buildRefreshSessionJson(String sessionId, String sessionPwd, String requestId) {
return """ return """
{ {
"op": "RefreshSession", "op": "RefreshSession",
"requestId": "%s", "requestId": "%s",
"payload": { "payload": {
"sessionId": "%s", "sessionId": "%s",
"sessionPwd": "%s" "sessionPwd": "%s",
} "clientInfo": "%s"
} }
""".formatted( }
""".formatted(
requestId, requestId,
sessionId, sessionId,
sessionPwd sessionPwd,
TEST_CLIENT_INFO
); );
} }
@ -456,6 +475,19 @@ public class Test_AddUser_and_Authorification {
// JSON HELPERS // JSON HELPERS
// ========================================================== // ==========================================================
private static String extractAuthNonce(String json) {
try {
JsonNode root = JSON_MAPPER.readTree(json);
JsonNode payload = root.get("payload");
if (payload != null && payload.has("authNonce")) {
return payload.get("authNonce").asText();
}
} catch (Exception e) {
System.out.println("⚠️ Не удалось распарсить authNonce из ответа: " + e.getMessage());
}
return null;
}
private static String extractSessionPwd(String json) { private static String extractSessionPwd(String json) {
try { try {
JsonNode root = JSON_MAPPER.readTree(json); JsonNode root = JSON_MAPPER.readTree(json);
@ -494,4 +526,4 @@ public class Test_AddUser_and_Authorification {
} }
return null; return null;
} }
} }