Авторификация работает и тест авторификации проходит.

(создание пользователя, два этапа создания сессии и рефреш сессии)
This commit is contained in:
AidarKC 2025-12-09 20:04:18 +03:00
parent 2ed4f6d666
commit 888bb1595f
12 changed files with 723 additions and 372 deletions

View File

@ -1,6 +1,5 @@
package shine.db;
import utils.config.AppConfig;
import java.io.BufferedReader;
@ -12,6 +11,15 @@ import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
/**
* DatabaseInitializer создание новой SQLite-БД по схеме SHiNE.
*
* Читает путь к файлу БД из application.properties (db.path),
* при необходимости удаляет старый файл и создаёт таблицы:
* - solana_users
* - active_sessions
* - users_params
*/
public class DatabaseInitializer {
public static void createNewDB(String[] args) {
@ -75,8 +83,8 @@ public class DatabaseInitializer {
login TEXT NOT NULL,
loginId INTEGER NOT NULL PRIMARY KEY,
bchId INTEGER NOT NULL,
pubkey0 TEXT,
pubkey1 TEXT,
loginKey TEXT, -- основной публичный ключ (логин)
deviceKey TEXT, -- публичный ключ устройства
bchLimit INTEGER -- может быть NULL
);
""");
@ -87,22 +95,29 @@ public class DatabaseInitializer {
""");
// 2. Таблица active_sessions
// sessionId теперь TEXT (base64 от 32 байт), а не INTEGER.
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS active_sessions (
sessionId INTEGER NOT NULL PRIMARY KEY,
session_pwd TEXT NOT NULL,
sessionId TEXT NOT NULL PRIMARY KEY,
loginId INTEGER NOT NULL,
time_ms INTEGER NOT NULL,
pubkey_num INTEGER NOT NULL,
push_endpoint TEXT,
push_p256dh_key TEXT,
push_auth_key TEXT,
sessionPwd TEXT NOT NULL,
storagePwd TEXT NOT NULL,
sessionCreatedAtMs INTEGER NOT NULL,
lastAuthirificatedAtMs INTEGER NOT NULL,
pushEndpoint TEXT,
pushP256dhKey TEXT,
pushAuthKey TEXT,
FOREIGN KEY (loginId) REFERENCES solana_users(loginId)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_active_sessions_loginId
ON active_sessions (loginId);
""");
// 3. Таблица users_params
// Важно: пара (loginId, param) должна быть уникальна
// Пара (loginId, param) должна быть уникальна.
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS users_params (
loginId INTEGER NOT NULL,

View File

@ -5,7 +5,11 @@ import shine.db.entities.ActiveSession;
import java.sql.*;
/** Здесь мы храним данные об активных сессиях пользователя (для wss соединений). */
/**
* DAO для таблицы active_sessions.
*
* Здесь мы храним данные об активных сессиях пользователя (для wss-соединений).
*/
public final class ActiveSessionsDAO {
private static volatile ActiveSessionsDAO instance;
@ -25,18 +29,21 @@ public final class ActiveSessionsDAO {
return instance;
}
/**
* Вставка новой сессии.
*/
public void insert(ActiveSession session) throws SQLException {
String sql = """
INSERT INTO active_sessions (
sessionId,
loginId,
session_pwd,
storage_pwd,
session_created_ms,
last_auth_ms,
push_endpoint,
push_p256dh_key,
push_auth_key
sessionPwd,
storagePwd,
sessionCreatedAtMs,
lastAuthirificatedAtMs,
pushEndpoint,
pushP256dhKey,
pushAuthKey
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
@ -50,22 +57,26 @@ public final class ActiveSessionsDAO {
ps.setString(7, session.getPushEndpoint());
ps.setString(8, session.getPushP256dhKey());
ps.setString(9, session.getPushAuthKey());
ps.executeUpdate();
}
}
/**
* Получить сессию по sessionId.
*/
public ActiveSession getBySessionId(String sessionId) throws SQLException {
String sql = """
SELECT
sessionId,
loginId,
session_pwd,
storage_pwd,
session_created_ms,
last_auth_ms,
push_endpoint,
push_p256dh_key,
push_auth_key
sessionPwd,
storagePwd,
sessionCreatedAtMs,
lastAuthirificatedAtMs,
pushEndpoint,
pushP256dhKey,
pushAuthKey
FROM active_sessions
WHERE sessionId = ?
""";
@ -81,6 +92,23 @@ public final class ActiveSessionsDAO {
}
}
/**
* Обновить только lastAuthirificatedAtMs для конкретной сессии.
*/
public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException {
String sql = """
UPDATE active_sessions
SET lastAuthirificatedAtMs = ?
WHERE sessionId = ?
""";
try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) {
ps.setLong(1, lastAuthMs);
ps.setString(2, sessionId);
ps.executeUpdate();
}
}
/**
* Удаление записи по sessionId.
* Если записи нет просто ничего не удалит (0 строк).
@ -94,42 +122,24 @@ public final class ActiveSessionsDAO {
}
}
/**
* Обновить поле last_auth_ms (lastAuthirificatedAtMs) для конкретной сессии.
* Остальные поля записи не меняются.
*/
public void updateLastAuthirificatedAtMs(String sessionId, long newTimeMs) throws SQLException {
String sql = """
UPDATE active_sessions
SET last_auth_ms = ?
WHERE sessionId = ?
""";
try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) {
ps.setLong(1, newTimeMs);
ps.setString(2, sessionId);
ps.executeUpdate();
}
}
private ActiveSession mapRow(ResultSet rs) throws SQLException {
String sessionId = rs.getString("sessionId");
long loginId = rs.getLong("loginId");
String sessionPwd = rs.getString("session_pwd");
String storagePwd = rs.getString("storage_pwd");
long sessionCreatedMs = rs.getLong("session_created_ms");
long lastAuthMs = rs.getLong("last_auth_ms");
String pushEndpoint = rs.getString("push_endpoint");
String pushP256dhKey = rs.getString("push_p256dh_key");
String pushAuthKey = rs.getString("push_auth_key");
String sessionPwd = rs.getString("sessionPwd");
String storagePwd = rs.getString("storagePwd");
long sessionCreatedAtMs = rs.getLong("sessionCreatedAtMs");
long lastAuthirificatedAtMs = rs.getLong("lastAuthirificatedAtMs");
String pushEndpoint = rs.getString("pushEndpoint");
String pushP256dhKey = rs.getString("pushP256dhKey");
String pushAuthKey = rs.getString("pushAuthKey");
return new ActiveSession(
sessionId,
loginId,
sessionPwd,
storagePwd,
sessionCreatedMs,
lastAuthMs,
sessionCreatedAtMs,
lastAuthirificatedAtMs,
pushEndpoint,
pushP256dhKey,
pushAuthKey

View File

@ -7,8 +7,17 @@ import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/** Здесь храним данные об пользователях - локальная копия того что есть в солане */
/**
* SolanaUsersDAO локальная таблица пользователей из Solana.
*
* Колонки:
* - login TEXT
* - loginId INTEGER (PK)
* - bchId INTEGER
* - loginKey TEXT
* - deviceKey TEXT
* - bchLimit INTEGER (может быть NULL)
*/
public final class SolanaUsersDAO {
private static volatile SolanaUsersDAO instance;
@ -29,7 +38,7 @@ public final class SolanaUsersDAO {
public void insert(SolanaUser user) throws SQLException {
String sql = """
INSERT INTO solana_users (login, loginId, bchId, pubkey0, pubkey1, bchLimit)
INSERT INTO solana_users (login, loginId, bchId, loginKey, deviceKey, bchLimit)
VALUES (?, ?, ?, ?, ?, ?)
""";
@ -37,8 +46,8 @@ public final class SolanaUsersDAO {
ps.setString(1, user.getLogin());
ps.setLong(2, user.getLoginId());
ps.setLong(3, user.getBchId());
ps.setString(4, user.getPubkey0());
ps.setString(5, user.getPubkey1());
ps.setString(4, user.getLoginKey());
ps.setString(5, user.getDeviceKey());
if (user.getBchLimit() != null) {
ps.setInt(6, user.getBchLimit());
@ -52,7 +61,7 @@ public final class SolanaUsersDAO {
public SolanaUser getByLoginId(long loginId) throws SQLException {
String sql = """
SELECT login, loginId, bchId, pubkey0, pubkey1, bchLimit
SELECT login, loginId, bchId, loginKey, deviceKey, bchLimit
FROM solana_users
WHERE loginId = ?
""";
@ -69,7 +78,7 @@ public final class SolanaUsersDAO {
public SolanaUser getByLogin(String login) throws SQLException {
String sql = """
SELECT login, loginId, bchId, pubkey0, pubkey1, bchLimit
SELECT login, loginId, bchId, loginKey, deviceKey, bchLimit
FROM solana_users
WHERE LOWER(login) = LOWER(?)
""";
@ -86,7 +95,7 @@ public final class SolanaUsersDAO {
public List<SolanaUser> searchByLoginPrefix(String prefix) throws SQLException {
String sql = """
SELECT login, loginId, bchId, pubkey0, pubkey1, bchLimit
SELECT login, loginId, bchId, loginKey, deviceKey, bchLimit
FROM solana_users
WHERE LOWER(login) LIKE ?
ORDER BY login
@ -111,8 +120,8 @@ public final class SolanaUsersDAO {
rs.getLong("loginId"),
rs.getString("login"),
rs.getLong("bchId"),
rs.getString("pubkey0"),
rs.getString("pubkey1"),
rs.getString("loginKey"),
rs.getString("deviceKey"),
rs.getObject("bchLimit") != null ? rs.getInt("bchLimit") : null
);
}

View File

@ -1,30 +1,34 @@
package shine.db.entities;
/**
* ActiveSession запись об активной сессии пользователя.
* Модель активной сессии (таблица active_sessions).
*
* Поля:
* - sessionId строка (base64 от 32 байт)
* - loginId long
* - sessionPwd строка (секрет шага 1)
* - storagePwd строка (секрет клиента для хранения данных)
* - sessionCreatedAtMs long (время создания)
* - lastAuthirificatedAtMs long (последнее подтверждение/refresh)
* - pushEndpoint строка (WebPush, пока null/пусто)
* - pushP256dhKey строка (WebPush, пока null/пусто)
* - pushAuthKey строка (WebPush, пока null/пусто)
* Поля соответствуют схеме:
*
* 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,
* FOREIGN KEY (loginId) REFERENCES solana_users(loginId)
* );
*/
public class ActiveSession {
private String sessionId;
private long loginId;
private String sessionPwd;
private String storagePwd;
private long sessionCreatedAtMs;
private long lastAuthirificatedAtMs;
private String pushEndpoint;
private String pushP256dhKey;
private String pushAuthKey;
private String sessionId; // TEXT base64(32 bytes)
private long loginId; // INTEGER
private String sessionPwd; // TEXT
private String storagePwd; // TEXT
private long sessionCreatedAtMs; // INTEGER
private long lastAuthirificatedAtMs; // INTEGER
private String pushEndpoint; // TEXT (nullable)
private String pushP256dhKey; // TEXT (nullable)
private String pushAuthKey; // TEXT (nullable)
public ActiveSession() {
}
@ -49,9 +53,12 @@ public class ActiveSession {
this.pushAuthKey = pushAuthKey;
}
// --- getters / setters ---
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
@ -59,6 +66,7 @@ public class ActiveSession {
public long getLoginId() {
return loginId;
}
public void setLoginId(long loginId) {
this.loginId = loginId;
}
@ -66,6 +74,7 @@ public class ActiveSession {
public String getSessionPwd() {
return sessionPwd;
}
public void setSessionPwd(String sessionPwd) {
this.sessionPwd = sessionPwd;
}
@ -73,6 +82,7 @@ public class ActiveSession {
public String getStoragePwd() {
return storagePwd;
}
public void setStoragePwd(String storagePwd) {
this.storagePwd = storagePwd;
}
@ -80,6 +90,7 @@ public class ActiveSession {
public long getSessionCreatedAtMs() {
return sessionCreatedAtMs;
}
public void setSessionCreatedAtMs(long sessionCreatedAtMs) {
this.sessionCreatedAtMs = sessionCreatedAtMs;
}
@ -87,6 +98,7 @@ public class ActiveSession {
public long getLastAuthirificatedAtMs() {
return lastAuthirificatedAtMs;
}
public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) {
this.lastAuthirificatedAtMs = lastAuthirificatedAtMs;
}
@ -94,6 +106,7 @@ public class ActiveSession {
public String getPushEndpoint() {
return pushEndpoint;
}
public void setPushEndpoint(String pushEndpoint) {
this.pushEndpoint = pushEndpoint;
}
@ -101,6 +114,7 @@ public class ActiveSession {
public String getPushP256dhKey() {
return pushP256dhKey;
}
public void setPushP256dhKey(String pushP256dhKey) {
this.pushP256dhKey = pushP256dhKey;
}
@ -108,6 +122,7 @@ public class ActiveSession {
public String getPushAuthKey() {
return pushAuthKey;
}
public void setPushAuthKey(String pushAuthKey) {
this.pushAuthKey = pushAuthKey;
}

View File

@ -1,12 +1,22 @@
package shine.db.entities;
/**
* Локальная копия пользователя из Solana.
*
* Храним:
* - login / loginId;
* - bchId id персонального блокчейна;
* - loginKey публичный ключ для логина / авторизации;
* - deviceKey публичный ключ устройства (второй ключ);
* - bchLimit лимит по количеству блоков / размеру цепочки (может быть null).
*/
public class SolanaUser {
private long loginId;
private String login;
private long bchId;
private String pubkey0;
private String pubkey1;
private String loginKey; // раньше pubkey0
private String deviceKey; // раньше pubkey1
private Integer bchLimit; // может быть null
public SolanaUser() {
@ -15,14 +25,14 @@ public class SolanaUser {
public SolanaUser(long loginId,
String login,
long bchId,
String pubkey0,
String pubkey1,
String loginKey,
String deviceKey,
Integer bchLimit) {
this.loginId = loginId;
this.login = login;
this.bchId = bchId;
this.pubkey0 = pubkey0;
this.pubkey1 = pubkey1;
this.loginKey = loginKey;
this.deviceKey = deviceKey;
this.bchLimit = bchLimit;
}
@ -50,20 +60,22 @@ public class SolanaUser {
this.bchId = bchId;
}
public String getPubkey0() {
return pubkey0;
/** Публичный ключ логина (основной ключ пользователя). */
public String getLoginKey() {
return loginKey;
}
public void setPubkey0(String pubkey0) {
this.pubkey0 = pubkey0;
public void setLoginKey(String loginKey) {
this.loginKey = loginKey;
}
public String getPubkey1() {
return pubkey1;
/** Публичный ключ устройства (device key). */
public String getDeviceKey() {
return deviceKey;
}
public void setPubkey1(String pubkey1) {
this.pubkey1 = pubkey1;
public void setDeviceKey(String deviceKey) {
this.deviceKey = deviceKey;
}
public Integer getBchLimit() {

View File

@ -3,27 +3,32 @@ package server.logic.ws_protocol.JSON.entyties.tempToTest;
import server.logic.ws_protocol.JSON.entyties.NetRequest;
/**
* Запрос AddUser.
*.
* Ожидаемый JSON:
* Запрос AddUser временная/тестовая регистрация локального пользователя.
*
* Клиент отправляет:
*
* {
* "op": "AddUser",
* "requestId": "...",
* "login": "...",
* "loginId": 123,
* "bchId": 456,
* "pubkey0": "...",
* "pubkey1": "...",
* "bchLimit": 1000
* "requestId": "test-add-1",
* "payload": {
* "login": "anya",
* "loginId": 100211,
* "bchId": 4222,
* "loginKey": "base64-ed25519-public-key-login",
* "deviceKey": "base64-ed25519-public-key-device",
* "bchLimit": 1000000
* }
* }
*
* Все поля лежат внутри payload.
*/
public class NetAddUserRequest extends NetRequest {
private String login;
private long loginId;
private long bchId;
private String pubkey0;
private String pubkey1;
private String loginKey;
private String deviceKey;
private Integer bchLimit;
public String getLogin() {
@ -50,20 +55,20 @@ public class NetAddUserRequest extends NetRequest {
this.bchId = bchId;
}
public String getPubkey0() {
return pubkey0;
public String getLoginKey() {
return loginKey;
}
public void setPubkey0(String pubkey0) {
this.pubkey0 = pubkey0;
public void setLoginKey(String loginKey) {
this.loginKey = loginKey;
}
public String getPubkey1() {
return pubkey1;
public String getDeviceKey() {
return deviceKey;
}
public void setPubkey1(String pubkey1) {
this.pubkey1 = pubkey1;
public void setDeviceKey(String deviceKey) {
this.deviceKey = deviceKey;
}
public Integer getBchLimit() {

View File

@ -4,8 +4,17 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
/**
* Успешный ответ на AddUser.
* Дополнительных полей нет достаточно status=200.
*
* Сейчас дополнительных полей нет достаточно status=200.
*
* Пример:
* {
* "op": "AddUser",
* "requestId": "test-add-1",
* "status": 200,
* "payload": { }
* }
*/
public class NetAddUserResponse extends NetResponse {
// Можно потом добавить какие-то данные, если понадобится.
// При необходимости сюда можно добавить, например, флаг created/updated и т.п.
}

View File

@ -119,7 +119,7 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
}
// --- выбираем публичный ключ pubkey1 ---
String pubKeyB64 = user.getPubkey1();
String pubKeyB64 = user.getDeviceKey();
if (pubKeyB64 == null || pubKeyB64.isBlank()) {
return NetExceptionResponseFactory.error(
req,

View File

@ -16,7 +16,25 @@ import shine.db.entities.SolanaUser;
import java.sql.SQLException;
/**
* Временный хэндлер AddUser (тестовая регистрация).
* Временный хэндлер AddUser (тестовая регистрация локального пользователя).
*
* Ожидаемый запрос (все поля в payload):
* {
* "op": "AddUser",
* "requestId": "...",
* "payload": {
* "login": "anya",
* "loginId": 100211,
* "bchId": 4222,
* "loginKey": "base64-pubkey-login",
* "deviceKey": "base64-pubkey-device",
* "bchLimit": 1000000
* }
* }
*
* При успехе:
* - пользователь сохраняется в таблицу solana_users;
* - возвращается status=200 и пустой payload.
*/
public class NetAddUserHandler implements JsonMessageHandler {
@ -28,15 +46,15 @@ public class NetAddUserHandler implements JsonMessageHandler {
// Одна общая проверка всех ключевых полей
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getPubkey0() == null || req.getPubkey0().isBlank()
|| req.getPubkey1() == null || req.getPubkey1().isBlank()
|| req.getLoginKey() == null || req.getLoginKey().isBlank()
|| req.getDeviceKey() == null || req.getDeviceKey().isBlank()
|| req.getBchLimit() == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные или пустые поля: login, pubkey0, pubkey1, bchLimit"
"Некорректные или пустые поля: login, loginKey, deviceKey, bchLimit"
);
}
@ -47,8 +65,8 @@ public class NetAddUserHandler implements JsonMessageHandler {
req.getLoginId(),
req.getLogin(),
req.getBchId(),
req.getPubkey0(),
req.getPubkey1(),
req.getLoginKey(),
req.getDeviceKey(),
req.getBchLimit()
);
@ -58,7 +76,7 @@ public class NetAddUserHandler implements JsonMessageHandler {
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
// payload сам станет {} через JsonInboundProcessor
// payload станет {} через JsonInboundProcessor
log.info("✅ Пользователь добавлен: login={}, loginId={}", req.getLogin(), req.getLoginId());
return resp;

View File

@ -8,3 +8,6 @@ user@p628065:~/docker/ws-server$ nohup java -jar ws-server.jar > server.log 2>&1
перестартовать кадди
user@p628065:~/docker/ws-server$ docker restart caddy
очистить того кто держит порт 7070 :)
kill -9 $(lsof -t -i:7070)

View File

@ -1,242 +0,0 @@
package Test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import utils.crypto.Ed25519Util;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.net.http.WebSocket.Listener;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
public class Test_AddUser_FirstAuth {
// Адрес сервера
private static final String WS_URI = "ws://localhost:7070/ws";
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
// Тестовые данные пользователя
private static final String TEST_LOGIN = "anya2";
private static final long TEST_LOGIN_ID = 100212L;
private static final long TEST_BCH_ID = 4222L;
private static final int TEST_BCH_LIMIT = 1_000_000;
// Тестовая пара ключей Ed25519 (стабильная, чтобы поведение не прыгало)
private static final byte[] TEST_PRIV_KEY;
private static final String TEST_PUBKEY_B64;
static {
// Можно сделать детерминированно от логина, чтобы всегда были одинаковые ключи
TEST_PRIV_KEY = Ed25519Util.generatePrivateKeyFromString("test-ed25519-" + TEST_LOGIN);
byte[] pub = Ed25519Util.derivePublicKey(TEST_PRIV_KEY);
TEST_PUBKEY_B64 = Ed25519Util.keyToBase64(pub);
}
public static void main(String[] args) throws Exception {
System.out.println("Подключаемся к " + WS_URI);
CountDownLatch latch = new CountDownLatch(1);
HttpClient client = HttpClient.newHttpClient();
ClientListener listener = new ClientListener(latch);
client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), listener)
.join();
// Ждём, пока всё не завершится (успех/ошибка/закрытие)
latch.await();
System.out.println("Тест завершён, выходим.");
}
// --- вспомогательные билдера JSON-запросов ---
// 1) Добавление пользователя
private static String buildAddUserJson() {
return """
{
"op": "AddUser",
"requestId": "test-add-1",
"payload": {
"login": "%s",
"loginId": %d,
"bchId": %d,
"pubkey0": "%s",
"pubkey1": "%s",
"bchLimit": %d
}
}
""".formatted(
TEST_LOGIN,
TEST_LOGIN_ID,
TEST_BCH_ID,
TEST_PUBKEY_B64, // pubkey0
TEST_PUBKEY_B64, // pubkey1 (для теста можно тот же)
TEST_BCH_LIMIT
);
}
// 2) Шаг 1 авторизации: запрос sessionPwd
private static String buildAuthStep1Json() {
return """
{
"op": "AuthSessionNewStep1",
"requestId": "test-auth-1",
"payload": {
"login": "%s"
}
}
""".formatted(TEST_LOGIN);
}
// 3) Шаг 2 авторизации: подтверждение подписью
private static String buildAuthStep2Json(String sessionPwd) {
if (sessionPwd == null) {
sessionPwd = "";
}
long timeMs = System.currentTimeMillis();
// preimage = loginId + timeMs + sessionPwd
String preimageStr = String.valueOf(TEST_LOGIN_ID) + timeMs + sessionPwd;
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
// Подписываем приватным ключом
byte[] sig = Ed25519Util.sign(preimage, TEST_PRIV_KEY);
String sigB64 = Base64.getEncoder().encodeToString(sig);
return """
{
"op": "AuthSessionNewStep2",
"requestId": "test-auth-2",
"payload": {
"loginId": %d,
"sigNum": 0,
"timeMs": %d,
"signatureB64": "%s"
}
}
""".formatted(
TEST_LOGIN_ID,
timeMs,
sigB64
);
}
// ================== LISTENER ==================
// Внутренний Listener, который сам по шагам шлёт запросы и печатает ответы
private static class ClientListener implements Listener {
private final CountDownLatch latch;
private int step = 0; // 0 - AddUser, 1 - AuthStep1, 2 - AuthStep2
private String sessionPwdFromStep1;
ClientListener(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("✅ WebSocket подключен");
// Разрешаем приём первого сообщения
webSocket.request(1);
sendNextRequest(webSocket);
Listener.super.onOpen(webSocket);
}
// Отправка следующего запроса в зависимости от шага
private void sendNextRequest(WebSocket webSocket) {
switch (step) {
case 0 -> {
String json = buildAddUserJson();
System.out.println();
System.out.println("📤 [Шаг 1] Отправляем AddUser:");
System.out.println(json);
webSocket.sendText(json, true);
}
case 1 -> {
String json = buildAuthStep1Json();
System.out.println();
System.out.println("📤 [Шаг 2] Отправляем AuthSessionNewStep1:");
System.out.println(json);
webSocket.sendText(json, true);
}
case 2 -> {
String json = buildAuthStep2Json(sessionPwdFromStep1);
System.out.println();
System.out.println("📤 [Шаг 3] Отправляем AuthSessionNewStep2 (подпись):");
System.out.println(json);
webSocket.sendText(json, true);
}
default -> {
System.out.println("Все шаги выполнены, закрываем соединение");
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "all tests done");
}
}
}
@Override
public CompletionStage<?> onText(WebSocket webSocket,
CharSequence data,
boolean last) {
String message = data.toString();
System.out.println("📥 Ответ на шаг " + (step + 1) + ":");
System.out.println(message);
System.out.println("-----------------------------------------------------");
// Если это ответ на шаг 2 (AuthSessionNewStep1) достаем sessionPwd из payload
if (step == 1) {
sessionPwdFromStep1 = extractSessionPwd(message);
System.out.println("🔑 Извлечён sessionPwd: " + sessionPwdFromStep1);
}
// Переходим к следующему шагу
step++;
sendNextRequest(webSocket);
// Запрашиваем следующее входящее сообщение
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
System.out.println("❌ Ошибка WebSocket-клиента: " + error.getMessage());
error.printStackTrace(System.out);
latch.countDown();
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket,
int statusCode,
String reason) {
System.out.println("🔚 Соединение закрыто. Код=" + statusCode + ", причина=" + reason);
latch.countDown();
return CompletableFuture.completedFuture(null);
}
private String extractSessionPwd(String json) {
try {
JsonNode root = JSON_MAPPER.readTree(json);
JsonNode payload = root.get("payload");
if (payload != null && payload.has("sessionPwd")) {
return payload.get("sessionPwd").asText();
}
} catch (Exception e) {
System.out.println("⚠️ Не удалось распарсить sessionPwd из ответа: " + e.getMessage());
}
return null;
}
}
}

View File

@ -0,0 +1,497 @@
package Test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import utils.crypto.Ed25519Util;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.net.http.WebSocket.Listener;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
/**
* Полный тестовый сценарий:
*
* 1) AddUser добавляем пользователя в локальную БД
* (loginKey и deviceKey разные).
*
* 2) AuthSessionNewStep1 запрашиваем sessionPwd.
*
* 3) AuthSessionNewStep2 подтверждаем владение deviceKey,
* создаётся сессия, сервер возвращает sessionId (строка).
*
* 4) Новое подключение:
* - отправляем SessionRefresh с тем же sessionId,
* но заведомо неверным sessionPwd
* (в консоль пишем: ожидаем ОТРИЦАТЕЛЬНЫЙ ответ).
*
* 5) Ещё одно новое подключение:
* - отправляем SessionRefresh с sessionId
* и корректным sessionPwd
* (в консоль пишем: ожидаем УСПЕШНЫЙ ответ).
*/
public class Test_AddUser_and_Authorification {
// Адрес сервера
private static final String WS_URI = "ws://localhost:7070/ws";
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
// Тестовые данные пользователя
private static final String TEST_LOGIN = "anya1";
private static final long TEST_LOGIN_ID = 100310L;
private static final long TEST_BCH_ID = 4222L;
private static final int TEST_BCH_LIMIT = 1_000_000;
// --- Тестовые пары ключей ---
// loginKey ключ аккаунта (например, "основной")
// deviceKey ключ устройства, которым подписываем авторизацию
private static final byte[] LOGIN_PRIV_KEY;
private static final String LOGIN_PUBKEY_B64;
private static final byte[] DEVICE_PRIV_KEY;
private static final String DEVICE_PUBKEY_B64;
static {
// Детерминированное "семя" для логин-ключа
LOGIN_PRIV_KEY = Ed25519Util.generatePrivateKeyFromString("test-ed25519-login-11" + TEST_LOGIN);
byte[] loginPub = Ed25519Util.derivePublicKey(LOGIN_PRIV_KEY);
LOGIN_PUBKEY_B64 = Ed25519Util.keyToBase64(loginPub);
// Детерминированное "семя" для девайс-ключа
DEVICE_PRIV_KEY = Ed25519Util.generatePrivateKeyFromString("test-ed25519-device-" + TEST_LOGIN);
byte[] devicePub = Ed25519Util.derivePublicKey(DEVICE_PRIV_KEY);
DEVICE_PUBKEY_B64 = Ed25519Util.keyToBase64(devicePub);
}
// --- Глобальные переменные между сценариями ---
/** sessionPwd, выданный на шаге AuthSessionNewStep1. */
private static String GLOBAL_SESSION_PWD;
/** sessionId (строка, base64-32 байта), выданный на шаге AuthSessionNewStep2. */
private static String GLOBAL_SESSION_ID;
/** storagePwd, который мы отправили при AuthSessionNewStep2 (для информации). */
private static String GLOBAL_STORAGE_PWD_SENT;
public static void main(String[] args) throws Exception {
System.out.println("Подключаемся к " + WS_URI);
// Сценарий 1: регистрация + первичная авторизация
runScenario_AddUser_And_FirstAuth();
// Сценарий 2: новое подключение, SessionRefresh с неверным sessionPwd
runScenario_SessionRefresh_WrongPwd();
// Сценарий 3: новое подключение, SessionRefresh с корректным sessionPwd
runScenario_SessionRefresh_CorrectPwd();
System.out.println("Все тесты завершены, выходим.");
}
// ==========================================================
// SCENARIO 1: AddUser + Auth
// ==========================================================
private static void runScenario_AddUser_And_FirstAuth() throws Exception {
System.out.println();
System.out.println("=== СЦЕНАРИЙ 1: AddUser + AuthSessionNewStep1 + AuthSessionNewStep2 ===");
CountDownLatch latch = new CountDownLatch(1);
HttpClient client = HttpClient.newHttpClient();
WebSocket ws = client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), new Listener() {
private int step = 0; // 0 - AddUser, 1 - AuthStep1, 2 - AuthStep2
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("✅ [S1] WebSocket подключен");
webSocket.request(1);
sendNextRequest(webSocket);
Listener.super.onOpen(webSocket);
}
private void sendNextRequest(WebSocket webSocket) {
switch (step) {
case 0 -> {
String json = buildAddUserJson();
System.out.println();
System.out.println("📤 [S1 / Шаг 1] Отправляем AddUser:");
System.out.println(json);
webSocket.sendText(json, true);
}
case 1 -> {
String json = buildAuthStep1Json();
System.out.println();
System.out.println("📤 [S1 / Шаг 2] Отправляем AuthSessionNewStep1:");
System.out.println(json);
webSocket.sendText(json, true);
}
case 2 -> {
GLOBAL_STORAGE_PWD_SENT = generateFakeStoragePwd();
String json = buildAuthStep2Json(GLOBAL_SESSION_PWD, GLOBAL_STORAGE_PWD_SENT);
System.out.println();
System.out.println("📤 [S1 / Шаг 3] Отправляем AuthSessionNewStep2 (подпись deviceKey):");
System.out.println(json);
webSocket.sendText(json, true);
}
default -> {
System.out.println("✅ [S1] Все шаги выполнены, закрываем соединение");
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario1 done");
}
}
}
@Override
public CompletionStage<?> onText(WebSocket webSocket,
CharSequence data,
boolean last) {
String message = data.toString();
System.out.println("📥 [S1] Ответ на шаг " + (step + 1) + ":");
System.out.println(message);
System.out.println("-----------------------------------------------------");
// Шаг 2: получаем sessionPwd
if (step == 1) {
GLOBAL_SESSION_PWD = extractSessionPwd(message);
System.out.println("🔑 [S1] Извлечён sessionPwd: " + GLOBAL_SESSION_PWD);
}
// Шаг 3: получаем sessionId
if (step == 2) {
GLOBAL_SESSION_ID = extractSessionId(message);
System.out.println("🆔 [S1] Извлечён sessionId: " + GLOBAL_SESSION_ID);
System.out.println(" (Эта sessionId и sessionPwd понадобятся в сценариях 2 и 3)");
}
step++;
sendNextRequest(webSocket);
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
System.out.println("❌ [S1] Ошибка WebSocket-клиента: " + error.getMessage());
error.printStackTrace(System.out);
latch.countDown();
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket,
int statusCode,
String reason) {
System.out.println("🔚 [S1] Соединение закрыто. Код=" + statusCode + ", причина=" + reason);
latch.countDown();
return CompletableFuture.completedFuture(null);
}
}).join();
latch.await();
System.out.println("=== СЦЕНАРИЙ 1 завершён ===");
}
// ==========================================================
// SCENARIO 2: SessionRefresh с неправильным паролем
// ==========================================================
private static void runScenario_SessionRefresh_WrongPwd() throws Exception {
System.out.println();
System.out.println("=== СЦЕНАРИЙ 2: SessionRefresh с НЕВЕРНЫМ sessionPwd ===");
System.out.println("Ожидаем ОТРИЦАТЕЛЬНЫЙ ответ сервера (UNVERIFIED / SESSION_PWD_MISMATCH и т.п.)");
if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) {
System.out.println("⚠️ Нет sessionId или sessionPwd из сценария 1, пропускаем сценарий 2.");
return;
}
CountDownLatch latch = new CountDownLatch(1);
HttpClient client = HttpClient.newHttpClient();
// Специально подменяем пароль, чтобы сервер его НЕ принял
String wrongPwd = GLOBAL_SESSION_PWD + "_WRONG";
WebSocket ws = client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), new Listener() {
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("✅ [S2] WebSocket подключен");
webSocket.request(1);
String json = buildSessionRefreshJson(GLOBAL_SESSION_ID, wrongPwd, "test-refresh-wrong-1");
System.out.println();
System.out.println("📤 [S2] Отправляем SessionRefresh с НЕВЕРНЫМ sessionPwd:");
System.out.println(json);
webSocket.sendText(json, true);
Listener.super.onOpen(webSocket);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket,
CharSequence data,
boolean last) {
String message = data.toString();
System.out.println("📥 [S2] Ответ сервера (ожидаем ошибку):");
System.out.println(message);
System.out.println("-----------------------------------------------------");
System.out.println("💬 [S2] Если в ответе status != 200 и/или код ошибки про неверный пароль — это ПРАВИЛЬНОЕ поведение.");
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario2 done");
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
System.out.println("❌ [S2] Ошибка WebSocket-клиента: " + error.getMessage());
error.printStackTrace(System.out);
latch.countDown();
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket,
int statusCode,
String reason) {
System.out.println("🔚 [S2] Соединение закрыто. Код=" + statusCode + ", причина=" + reason);
latch.countDown();
return CompletableFuture.completedFuture(null);
}
}).join();
latch.await();
System.out.println("=== СЦЕНАРИЙ 2 завершён ===");
}
// ==========================================================
// SCENARIO 3: SessionRefresh с правильными данными
// ==========================================================
private static void runScenario_SessionRefresh_CorrectPwd() throws Exception {
System.out.println();
System.out.println("=== СЦЕНАРИЙ 3: SessionRefresh с КОРРЕКТНЫМ sessionPwd ===");
System.out.println("Ожидаем УСПЕШНЫЙ ответ сервера (status=200),");
System.out.println(" а в payload должен вернуться актуальный storagePwd (по твоей схеме).");
if (GLOBAL_SESSION_ID == null || GLOBAL_SESSION_PWD == null) {
System.out.println("⚠️ Нет sessionId или sessionPwd из сценария 1, пропускаем сценарий 3.");
return;
}
CountDownLatch latch = new CountDownLatch(1);
HttpClient client = HttpClient.newHttpClient();
WebSocket ws = client.newWebSocketBuilder()
.buildAsync(URI.create(WS_URI), new Listener() {
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("✅ [S3] WebSocket подключен");
webSocket.request(1);
String json = buildSessionRefreshJson(GLOBAL_SESSION_ID, GLOBAL_SESSION_PWD, "test-refresh-ok-1");
System.out.println();
System.out.println("📤 [S3] Отправляем SessionRefresh с КОРРЕКТНЫМ sessionPwd:");
System.out.println(json);
webSocket.sendText(json, true);
Listener.super.onOpen(webSocket);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket,
CharSequence data,
boolean last) {
String message = data.toString();
System.out.println("📥 [S3] Ответ сервера (ожидаем успех):");
System.out.println(message);
System.out.println("-----------------------------------------------------");
System.out.println("💬 [S3] Если status=200 — сессия успешно восстановлена.");
String storagePwdFromServer = extractStoragePwd(message);
System.out.println("🧾 [S3] storagePwd от сервера: " + storagePwdFromServer);
System.out.println(" (Может совпадать с тем, что был в шаге 2, или быть обновлённым — зависит от логики сервера)");
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "scenario3 done");
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
System.out.println("❌ [S3] Ошибка WebSocket-клиента: " + error.getMessage());
error.printStackTrace(System.out);
latch.countDown();
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket,
int statusCode,
String reason) {
System.out.println("🔚 [S3] Соединение закрыто. Код=" + statusCode + ", причина=" + reason);
latch.countDown();
return CompletableFuture.completedFuture(null);
}
}).join();
latch.await();
System.out.println("=== СЦЕНАРИЙ 3 завершён ===");
}
// ==========================================================
// JSON BUILDERS
// ==========================================================
// 1) AddUser с payload (loginKey != deviceKey)
private static String buildAddUserJson() {
return """
{
"op": "AddUser",
"requestId": "test-add-1",
"payload": {
"login": "%s",
"loginId": %d,
"bchId": %d,
"loginKey": "%s",
"deviceKey": "%s",
"bchLimit": %d
}
}
""".formatted(
TEST_LOGIN,
TEST_LOGIN_ID,
TEST_BCH_ID,
LOGIN_PUBKEY_B64, // loginKey
DEVICE_PUBKEY_B64, // deviceKey
TEST_BCH_LIMIT
);
}
// 2) Шаг 1 авторизации: запрос sessionPwd
private static String buildAuthStep1Json() {
return """
{
"op": "AuthSessionNewStep1",
"requestId": "test-auth-1",
"payload": {
"login": "%s"
}
}
""".formatted(TEST_LOGIN);
}
// 3) Шаг 2 авторизации: подтверждение подписью
// payload: storagePwd, timeMs, signatureB64
private static String buildAuthStep2Json(String sessionPwd, String storagePwd) {
if (sessionPwd == null) {
sessionPwd = "";
}
if (storagePwd == null || storagePwd.isBlank()) {
storagePwd = generateFakeStoragePwd();
}
long timeMs = System.currentTimeMillis();
// preimage = "AUTHORIFICATED:" + timeMs + sessionPwd
String preimageStr = "AUTHORIFICATED:" + timeMs + sessionPwd;
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
// Подписываем приватным ключом устройства (deviceKey)
byte[] sig = Ed25519Util.sign(preimage, DEVICE_PRIV_KEY);
String sigB64 = Base64.getEncoder().encodeToString(sig);
return """
{
"op": "AuthSessionNewStep2",
"requestId": "test-auth-2",
"payload": {
"storagePwd": "%s",
"timeMs": %d,
"signatureB64": "%s"
}
}
""".formatted(
storagePwd,
timeMs,
sigB64
);
}
// 4) SessionRefresh: всё в payload
private static String buildSessionRefreshJson(String sessionId, String sessionPwd, String requestId) {
return """
{
"op": "SessionRefresh",
"requestId": "%s",
"payload": {
"sessionId": "%s",
"sessionPwd": "%s"
}
}
""".formatted(
requestId,
sessionId,
sessionPwd
);
}
// просто для теста: base64 от 32 байт "storage" ключа
private static String generateFakeStoragePwd() {
byte[] data = new byte[32];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) (i + 1);
}
return Base64.getEncoder().encodeToString(data);
}
// ==========================================================
// JSON HELPERS
// ==========================================================
private static String extractSessionPwd(String json) {
try {
JsonNode root = JSON_MAPPER.readTree(json);
JsonNode payload = root.get("payload");
if (payload != null && payload.has("sessionPwd")) {
return payload.get("sessionPwd").asText();
}
} catch (Exception e) {
System.out.println("⚠️ Не удалось распарсить sessionPwd из ответа: " + e.getMessage());
}
return null;
}
private static String extractSessionId(String json) {
try {
JsonNode root = JSON_MAPPER.readTree(json);
JsonNode payload = root.get("payload");
if (payload != null && payload.has("sessionId")) {
return payload.get("sessionId").asText();
}
} catch (Exception e) {
System.out.println("⚠️ Не удалось распарсить sessionId из ответа: " + e.getMessage());
}
return null;
}
private static String extractStoragePwd(String json) {
try {
JsonNode root = JSON_MAPPER.readTree(json);
JsonNode payload = root.get("payload");
if (payload != null && payload.has("storagePwd")) {
return payload.get("storagePwd").asText();
}
} catch (Exception e) {
System.out.println("⚠️ Не удалось распарсить storagePwd из ответа: " + e.getMessage());
}
return null;
}
}