API сессий: добавить sessionType и clientPlatform

This commit is contained in:
AidarKC 2026-06-13 14:15:42 +04:00
parent 3b8ea70d3c
commit 919387f581
21 changed files with 446 additions and 15 deletions

View File

@ -17,6 +17,23 @@
- на втором шаге клиент присылает подписанный ответ; - на втором шаге клиент присылает подписанный ответ;
- сервер сверяет актуальные публичные ключи и только потом проверяет подпись. - сервер сверяет актуальные публичные ключи и только потом проверяет подпись.
Новые поля этого раздела:
- `sessionType` — числовой код типа сессии;
- `clientPlatform` — свободная строка платформы клиента.
Текущие поддерживаемые коды `sessionType`:
- `1` — обычный клиент;
- `50` — кошелёк;
- `100` — homeserver.
Правило проверки `sessionType`:
1. если в `Solana PDA` нет записи для `sessionKey`, сервер принимает `sessionType`, присланный клиентом;
2. если запись в `PDA` есть, `sessionType` в запросе должен совпадать с `session_type` из `PDA`;
3. при несовпадении сервер возвращает `460 / SESSION_TYPE_MISMATCH`.
Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов. Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов.
## 1. Поток авторизации ## 1. Поток авторизации
@ -94,6 +111,8 @@ ed25519/BASE64_PUBLIC_KEY
"authNonce": "nonce", "authNonce": "nonce",
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY", "deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
"signatureB64": "BASE64_SIGNATURE", "signatureB64": "BASE64_SIGNATURE",
"sessionType": 1,
"clientPlatform": "Web",
"clientInfo": "Android 15; Pixel 9" "clientInfo": "Android 15; Pixel 9"
} }
} }
@ -153,6 +172,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`. - `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`.
- `422 / DEVICE_KEY_NOT_ACTUAL``deviceKey` не совпадает с актуальной версией на сервере. - `422 / DEVICE_KEY_NOT_ACTUAL``deviceKey` не совпадает с актуальной версией на сервере.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку. - `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA.
- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA.
- `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии. - `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
@ -208,6 +229,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
"sessionKey": "ed25519/BASE64_PUBLIC_KEY", "sessionKey": "ed25519/BASE64_PUBLIC_KEY",
"timeMs": 1774600010456, "timeMs": 1774600010456,
"signatureB64": "BASE64_SIGNATURE", "signatureB64": "BASE64_SIGNATURE",
"sessionType": 1,
"clientPlatform": "Web",
"clientInfo": "Android 15; Pixel 9" "clientInfo": "Android 15; Pixel 9"
} }
} }
@ -258,6 +281,8 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером. - `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`. - `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку. - `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA.
- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA.
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии. - `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии.
- `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден. - `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.

View File

@ -42,6 +42,8 @@
"sessions": [ "sessions": [
{ {
"sessionId": "sess_7c5e5c4b", "sessionId": "sess_7c5e5c4b",
"sessionType": 1,
"clientPlatform": "Web",
"clientInfoFromClient": "Android 15; Pixel 9", "clientInfoFromClient": "Android 15; Pixel 9",
"clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1", "clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1",
"geo": "RU/Moscow", "geo": "RU/Moscow",
@ -58,6 +60,19 @@
- `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий. - `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
### Поля одной сессии в `ListSessions`
- `sessionId` — идентификатор активной сессии;
- `sessionType` — числовой код типа сессии:
- `1` — клиент;
- `50` — кошелёк;
- `100` — homeserver;
- `clientPlatform` — строка платформы, как её прислал клиент;
- `clientInfoFromClient` — краткая строка клиента;
- `clientInfoFromRequest` — строка, собранная сервером из запроса;
- `geo` — страна/город или fallback-строка;
- `lastAuthenticatedAtMs` — время последней успешной авторизации этой сессии.
--- ---
## 2. `CloseActiveSession` ## 2. `CloseActiveSession`

View File

@ -0,0 +1,21 @@
# sessionType API и UI
- краткое описание:
- серверный API авторизации и списка сессий расширен полями `sessionType` и `clientPlatform`;
- сервер сверяет `sessionType` с записью в Solana `PDA`, если для данного `sessionKey` уже есть `SessionRecord`;
- `shine-UI` теперь отправляет `sessionType=1` и `clientPlatform=Web`, а также показывает тип/платформу в экранах сессий;
- в документации Solana `PDA` добавлен тип `50` для `wallet`.
- что проверять:
- вход через `CreateAuthSession` и повторный вход через `SessionLogin`;
- ответ `ListSessions` должен содержать `sessionType` и `clientPlatform`;
- в UI "Устройства" должен отображаться тип сессии и платформа;
- при искусственном несовпадении `sessionType` с `PDA` сервер должен вернуть `460 / SESSION_TYPE_MISMATCH`.
- ожидаемый результат:
- обычный web-клиент успешно создаёт/возобновляет сессию с `sessionType=1`;
- список сессий возвращает новые поля;
- несовпадение типа с `PDA` даёт явную прикладную ошибку `460`.
- статус:
- in_progress

View File

@ -178,6 +178,8 @@ public final class DatabaseInitializer {
client_ip TEXT, client_ip TEXT,
client_info_from_client TEXT, client_info_from_client TEXT,
client_info_from_request TEXT, client_info_from_request TEXT,
session_type INTEGER NOT NULL DEFAULT 1,
client_platform TEXT NOT NULL DEFAULT '',
user_language TEXT, user_language TEXT,
FOREIGN KEY (login) REFERENCES solana_users(login) FOREIGN KEY (login) REFERENCES solana_users(login)
); );

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController { public final class SqliteDbController {
private static volatile SqliteDbController instance; private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 3; private static final int LATEST_SCHEMA_VERSION = 4;
private final String jdbcUrl; private final String jdbcUrl;
@ -86,6 +86,7 @@ public final class SqliteDbController {
case 1 -> migrateToV1(); case 1 -> migrateToV1();
case 2 -> migrateToV2(); case 2 -> migrateToV2();
case 3 -> migrateToV3(); case 3 -> migrateToV3();
case 4 -> migrateToV4();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
} }
} }
@ -168,6 +169,26 @@ public final class SqliteDbController {
} }
} }
private void migrateToV4() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureActiveSessionsSessionTypeColumn(c, st);
ensureActiveSessionsClientPlatformColumn(c, st);
setSchemaVersion(c, 4);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v4 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v4 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException { private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state ( CREATE TABLE IF NOT EXISTS chat200_state (
@ -235,6 +256,28 @@ public final class SqliteDbController {
} }
} }
private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException {
if (columnExists(c, "active_sessions", "session_type")) return;
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1");
}
private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException {
if (columnExists(c, "active_sessions", "client_platform")) return;
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''");
}
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
try (Statement probe = c.createStatement();
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
while (rs.next()) {
if (columnName.equalsIgnoreCase(rs.getString("name"))) {
return true;
}
}
return false;
}
}
private static void setSchemaVersion(Connection c, int version) throws SQLException { private static void setSchemaVersion(Connection c, int version) throws SQLException {
try (var ps = c.prepareStatement(""" try (var ps = c.prepareStatement("""
INSERT INTO db_schema_version (id, schema_version, updated_at_ms) INSERT INTO db_schema_version (id, schema_version, updated_at_ms)

View File

@ -47,8 +47,10 @@ public final class ActiveSessionsDAO {
client_ip, client_ip,
client_info_from_client, client_info_from_client,
client_info_from_request, client_info_from_request,
session_type,
client_platform,
user_language user_language
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
@ -64,7 +66,9 @@ public final class ActiveSessionsDAO {
ps.setString(10, session.getClientIp()); ps.setString(10, session.getClientIp());
ps.setString(11, session.getClientInfoFromClient()); ps.setString(11, session.getClientInfoFromClient());
ps.setString(12, session.getClientInfoFromRequest()); ps.setString(12, session.getClientInfoFromRequest());
ps.setString(13, session.getUserLanguage()); ps.setInt(13, session.getSessionType());
ps.setString(14, session.getClientPlatform());
ps.setString(15, session.getUserLanguage());
ps.executeUpdate(); ps.executeUpdate();
} }
} }
@ -92,6 +96,8 @@ public final class ActiveSessionsDAO {
client_ip, client_ip,
client_info_from_client, client_info_from_client,
client_info_from_request, client_info_from_request,
session_type,
client_platform,
user_language user_language
FROM active_sessions FROM active_sessions
WHERE session_id = ? WHERE session_id = ?
@ -127,6 +133,8 @@ public final class ActiveSessionsDAO {
client_ip, client_ip,
client_info_from_client, client_info_from_client,
client_info_from_request, client_info_from_request,
session_type,
client_platform,
user_language user_language
FROM active_sessions FROM active_sessions
WHERE login = ? COLLATE NOCASE WHERE login = ? COLLATE NOCASE
@ -179,6 +187,8 @@ public final class ActiveSessionsDAO {
String clientIp, String clientIp,
String clientInfoFromClient, String clientInfoFromClient,
String clientInfoFromRequest, String clientInfoFromRequest,
int sessionType,
String clientPlatform,
String userLanguage String userLanguage
) throws SQLException { ) throws SQLException {
@ -189,6 +199,8 @@ public final class ActiveSessionsDAO {
client_ip = ?, client_ip = ?,
client_info_from_client = ?, client_info_from_client = ?,
client_info_from_request = ?, client_info_from_request = ?,
session_type = ?,
client_platform = ?,
user_language = ? user_language = ?
WHERE session_id = ? WHERE session_id = ?
"""; """;
@ -198,8 +210,10 @@ public final class ActiveSessionsDAO {
ps.setString(2, clientIp); ps.setString(2, clientIp);
ps.setString(3, clientInfoFromClient); ps.setString(3, clientInfoFromClient);
ps.setString(4, clientInfoFromRequest); ps.setString(4, clientInfoFromRequest);
ps.setString(5, userLanguage); ps.setInt(5, sessionType);
ps.setString(6, sessionId); ps.setString(6, clientPlatform);
ps.setString(7, userLanguage);
ps.setString(8, sessionId);
ps.executeUpdate(); ps.executeUpdate();
} }
} }
@ -210,10 +224,12 @@ public final class ActiveSessionsDAO {
String clientIp, String clientIp,
String clientInfoFromClient, String clientInfoFromClient,
String clientInfoFromRequest, String clientInfoFromRequest,
int sessionType,
String clientPlatform,
String userLanguage String userLanguage
) throws SQLException { ) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage); updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, sessionType, clientPlatform, userLanguage);
} }
} }
@ -268,6 +284,8 @@ public final class ActiveSessionsDAO {
String clientIp = rs.getString("client_ip"); String clientIp = rs.getString("client_ip");
String clientInfoFromClient = rs.getString("client_info_from_client"); String clientInfoFromClient = rs.getString("client_info_from_client");
String clientInfoFromRequest = rs.getString("client_info_from_request"); String clientInfoFromRequest = rs.getString("client_info_from_request");
int sessionType = rs.getInt("session_type");
String clientPlatform = rs.getString("client_platform");
String userLanguage = rs.getString("user_language"); String userLanguage = rs.getString("user_language");
return new ActiveSessionEntry( return new ActiveSessionEntry(
@ -283,6 +301,8 @@ public final class ActiveSessionsDAO {
clientIp, clientIp,
clientInfoFromClient, clientInfoFromClient,
clientInfoFromRequest, clientInfoFromRequest,
sessionType,
clientPlatform,
userLanguage userLanguage
); );
} }

View File

@ -22,6 +22,8 @@ public class ActiveSessionEntry {
private String clientIp; private String clientIp;
private String clientInfoFromClient; private String clientInfoFromClient;
private String clientInfoFromRequest; private String clientInfoFromRequest;
private int sessionType;
private String clientPlatform;
private String userLanguage; private String userLanguage;
public ActiveSessionEntry() { } public ActiveSessionEntry() { }
@ -38,6 +40,8 @@ public class ActiveSessionEntry {
String clientIp, String clientIp,
String clientInfoFromClient, String clientInfoFromClient,
String clientInfoFromRequest, String clientInfoFromRequest,
int sessionType,
String clientPlatform,
String userLanguage) { String userLanguage) {
this.sessionId = sessionId; this.sessionId = sessionId;
this.login = login; this.login = login;
@ -51,6 +55,8 @@ public class ActiveSessionEntry {
this.clientIp = clientIp; this.clientIp = clientIp;
this.clientInfoFromClient = clientInfoFromClient; this.clientInfoFromClient = clientInfoFromClient;
this.clientInfoFromRequest = clientInfoFromRequest; this.clientInfoFromRequest = clientInfoFromRequest;
this.sessionType = sessionType;
this.clientPlatform = clientPlatform;
this.userLanguage = userLanguage; this.userLanguage = userLanguage;
} }
@ -90,6 +96,12 @@ public class ActiveSessionEntry {
public String getClientInfoFromRequest() { return clientInfoFromRequest; } public String getClientInfoFromRequest() { return clientInfoFromRequest; }
public void setClientInfoFromRequest(String clientInfoFromRequest) { this.clientInfoFromRequest = clientInfoFromRequest; } public void setClientInfoFromRequest(String clientInfoFromRequest) { this.clientInfoFromRequest = clientInfoFromRequest; }
public int getSessionType() { return sessionType; }
public void setSessionType(int sessionType) { this.sessionType = sessionType; }
public String getClientPlatform() { return clientPlatform; }
public void setClientPlatform(String clientPlatform) { this.clientPlatform = clientPlatform; }
public String getUserLanguage() { return userLanguage; } public String getUserLanguage() { return userLanguage; }
public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; } public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; }
} }

View File

@ -0,0 +1,46 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import java.util.Base64;
final class AuthSessionTypeSupport {
static final int SESSION_TYPE_CLIENT = 1;
static final int SESSION_TYPE_WALLET = 50;
static final int SESSION_TYPE_HOMESERVER = 100;
static final int SESSION_TYPE_MISMATCH_STATUS = 460;
private AuthSessionTypeSupport() {}
static int normalizeRequestedSessionType(Integer rawType) {
return rawType == null ? SESSION_TYPE_CLIENT : rawType.intValue();
}
static boolean isSupportedSessionType(int sessionType) {
return sessionType == SESSION_TYPE_CLIENT
|| sessionType == SESSION_TYPE_WALLET
|| sessionType == SESSION_TYPE_HOMESERVER;
}
static String normalizeClientPlatform(String clientPlatform) {
if (clientPlatform == null) return "";
String trimmed = clientPlatform.trim();
if (trimmed.length() <= 64) return trimmed;
return trimmed.substring(0, 64);
}
static byte[] tryParseSessionPublicKey32(String sessionKeyApi) {
if (sessionKeyApi == null || sessionKeyApi.isBlank()) return null;
try {
return AuthKeyUtils.parseEd25519PublicKey(sessionKeyApi, "sessionKey");
} catch (Exception ignored) {
try {
byte[] raw = Base64.getDecoder().decode(sessionKeyApi.trim());
return raw.length == 32 ? raw : null;
} catch (Exception ignoredToo) {
return null;
}
}
}
}

View File

@ -213,6 +213,19 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
clientInfoFromClient = clientInfoFromClient.substring(0, 50); clientInfoFromClient = clientInfoFromClient.substring(0, 50);
} }
int requestedSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getSessionType());
if (!AuthSessionTypeSupport.isSupportedSessionType(requestedSessionType)) {
Net_Response err = NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SESSION_TYPE",
"Неподдерживаемый sessionType"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: bad sessionType");
return err;
}
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform());
String deviceKeyFromDb = user.getDeviceKey(); String deviceKeyFromDb = user.getDeviceKey();
if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
@ -315,6 +328,35 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
SolanaUserPdaImportService.SessionTypeCheckResult sessionTypeCheck;
try {
sessionTypeCheck = SolanaUserPdaImportService.checkSessionTypeAgainstPda(
canonicalLogin,
sessionKey,
requestedSessionType
);
} catch (Exception e) {
log.error("Ошибка проверки sessionType по Solana PDA для login={}", canonicalLogin, e);
Net_Response err = NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"SESSION_TYPE_PDA_CHECK_FAILED",
"Ошибка проверки sessionType в Solana PDA"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: sessionType pda check");
return err;
}
if (sessionTypeCheck.hasPdaSessionRecord() && !sessionTypeCheck.matchesRequestedType()) {
Net_Response err = NetExceptionResponseFactory.error(
req,
AuthSessionTypeSupport.SESSION_TYPE_MISMATCH_STATUS,
"SESSION_TYPE_MISMATCH",
"sessionType не совпадает с типом сессии в Solana PDA"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: sessionType mismatch");
return err;
}
// --- генерируем sessionId --- // --- генерируем sessionId ---
String sessionId = generateRandom32B64Url(); String sessionId = generateRandom32B64Url();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
@ -356,6 +398,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
clientIp, clientIp,
clientInfoFromClient, clientInfoFromClient,
clientInfoFromRequest, clientInfoFromRequest,
requestedSessionType,
clientPlatform,
userLanguage userLanguage
); );

View File

@ -66,6 +66,8 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
info.setSessionId(s.getSessionId()); info.setSessionId(s.getSessionId());
info.setClientInfoFromClient(s.getClientInfoFromClient()); info.setClientInfoFromClient(s.getClientInfoFromClient());
info.setClientInfoFromRequest(s.getClientInfoFromRequest()); info.setClientInfoFromRequest(s.getClientInfoFromRequest());
info.setSessionType(s.getSessionType());
info.setClientPlatform(s.getClientPlatform());
info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs()); info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs());
String ip = s.getClientIp(); String ip = s.getClientIp();

View File

@ -216,6 +216,16 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) {
clientInfoFromClient = clientInfoFromClient.substring(0, 50); clientInfoFromClient = clientInfoFromClient.substring(0, 50);
} }
int requestedSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getSessionType());
if (!AuthSessionTypeSupport.isSupportedSessionType(requestedSessionType)) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SESSION_TYPE",
"Неподдерживаемый sessionType"
);
}
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform());
String clientIp = null; String clientIp = null;
String clientInfoFromRequest = null; String clientInfoFromRequest = null;
@ -235,6 +245,31 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
} }
} }
SolanaUserPdaImportService.SessionTypeCheckResult sessionTypeCheck;
try {
sessionTypeCheck = SolanaUserPdaImportService.checkSessionTypeAgainstPda(
session.getLogin(),
sessionKeyFromReq,
requestedSessionType
);
} catch (Exception e) {
log.error("Ошибка проверки sessionType по Solana PDA для login={} sessionId={}", session.getLogin(), sessionId, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"SESSION_TYPE_PDA_CHECK_FAILED",
"Ошибка проверки sessionType в Solana PDA"
);
}
if (sessionTypeCheck.hasPdaSessionRecord() && !sessionTypeCheck.matchesRequestedType()) {
return NetExceptionResponseFactory.error(
req,
AuthSessionTypeSupport.SESSION_TYPE_MISMATCH_STATUS,
"SESSION_TYPE_MISMATCH",
"sessionType не совпадает с типом сессии в Solana PDA"
);
}
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
try { try {
ActiveSessionsDAO.getInstance().updateOnRefresh( ActiveSessionsDAO.getInstance().updateOnRefresh(
@ -243,6 +278,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
clientIp, clientIp,
clientInfoFromClient, clientInfoFromClient,
clientInfoFromRequest, clientInfoFromRequest,
requestedSessionType,
clientPlatform,
userLanguage userLanguage
); );
} catch (SQLException e) { } catch (SQLException e) {
@ -253,6 +290,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
session.setClientIp(clientIp); session.setClientIp(clientIp);
session.setClientInfoFromClient(clientInfoFromClient); session.setClientInfoFromClient(clientInfoFromClient);
session.setClientInfoFromRequest(clientInfoFromRequest); session.setClientInfoFromRequest(clientInfoFromRequest);
session.setSessionType(requestedSessionType);
session.setClientPlatform(clientPlatform);
session.setUserLanguage(userLanguage); session.setUserLanguage(userLanguage);
// ctx // ctx

View File

@ -14,7 +14,10 @@ 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.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.List;
import java.util.Locale;
/** /**
* Lazy-import пользователя из Solana PDA в локальную БД сервера. * Lazy-import пользователя из Solana PDA в локальную БД сервера.
@ -57,6 +60,28 @@ public final class SolanaUserPdaImportService {
return usersDao.getByLogin(login); return usersDao.getByLogin(login);
} }
public static SessionTypeCheckResult checkSessionTypeAgainstPda(String loginRaw, String sessionKeyApi, int requestedSessionType) throws Exception {
String login = normalizeLogin(loginRaw);
if (login == null) return SessionTypeCheckResult.noRecord();
ParsedSolanaUser parsed = fetchFromSolana(login);
if (parsed == null) return SessionTypeCheckResult.noRecord();
byte[] sessionPubKey32 = AuthSessionTypeSupport.tryParseSessionPublicKey32(sessionKeyApi);
if (sessionPubKey32 == null) return SessionTypeCheckResult.noRecord();
for (ParsedSessionRecord session : parsed.sessions()) {
if (constantTimeEquals(session.sessionPubKey32(), sessionPubKey32)) {
if (session.sessionType() == requestedSessionType) {
return SessionTypeCheckResult.match(session.sessionType(), session.sessionName());
}
return SessionTypeCheckResult.mismatch(session.sessionType(), session.sessionName());
}
}
return SessionTypeCheckResult.noRecord();
}
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception { private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8)); String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
String lenB58 = toBase58(new byte[]{(byte) login.length()}); String lenB58 = toBase58(new byte[]{(byte) login.length()});
@ -135,6 +160,7 @@ public final class SolanaUserPdaImportService {
byte[] blockchainKey32 = null; byte[] blockchainKey32 = null;
byte[] deviceKey32 = null; byte[] deviceKey32 = null;
long paidLimitBytes = 0L; long paidLimitBytes = 0L;
List<ParsedSessionRecord> sessions = new ArrayList<>();
for (int i = 0; i < blocksCount; i++) { for (int i = 0; i < blocksCount; i++) {
int blockType = u8(raw, c++); int blockType = u8(raw, c++);
@ -196,11 +222,19 @@ public final class SolanaUserPdaImportService {
int sessionsCount = u8(raw, c++); int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null; if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) { for (int j = 0; j < sessionsCount; j++) {
c += 1; // session_type int sessionType = u8(raw, c++);
c += 1; // session_version int sessionVersion = u8(raw, c++);
int n = u8(raw, c++); int n = u8(raw, c++);
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
c += n; c += n;
c += 32; // session_pub_key byte[] sessionPubKey32 = slice(raw, c, 32);
c += 32;
sessions.add(new ParsedSessionRecord(
sessionType,
sessionVersion,
sessionName,
sessionPubKey32
));
} }
} else if (blockType == 70) { } else if (blockType == 70) {
c += 1; c += 1;
@ -217,7 +251,8 @@ public final class SolanaUserPdaImportService {
blockchainName, blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(deviceKey32), Base64.getEncoder().encodeToString(deviceKey32),
paidLimitBytes paidLimitBytes,
sessions
); );
} }
@ -225,7 +260,7 @@ public final class SolanaUserPdaImportService {
if (login == null) return null; if (login == null) return null;
String s = login.trim(); String s = login.trim();
if (s.isEmpty()) return null; if (s.isEmpty()) return null;
return s.toLowerCase(); return s.toLowerCase(Locale.ROOT);
} }
private static int u8(byte[] b, int o) { return b[o] & 0xFF; } private static int u8(byte[] b, int o) { return b[o] & 0xFF; }
@ -272,11 +307,45 @@ public final class SolanaUserPdaImportService {
return remainder; return remainder;
} }
private static boolean constantTimeEquals(byte[] a, byte[] b) {
if (a == null || b == null || a.length != b.length) return false;
int diff = 0;
for (int i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff == 0;
}
private record ParsedSolanaUser( private record ParsedSolanaUser(
String login, String login,
String blockchainName, String blockchainName,
String blockchainKeyB64, String blockchainKeyB64,
String deviceKeyB64, String deviceKeyB64,
long paidLimitBytes long paidLimitBytes,
List<ParsedSessionRecord> sessions
) {} ) {}
private record ParsedSessionRecord(
int sessionType,
int sessionVersion,
String sessionName,
byte[] sessionPubKey32
) {}
public record SessionTypeCheckResult(
boolean hasPdaSessionRecord,
boolean matchesRequestedType,
int pdaSessionType,
String sessionName
) {
static SessionTypeCheckResult noRecord() {
return new SessionTypeCheckResult(false, true, 0, "");
}
static SessionTypeCheckResult match(int pdaSessionType, String sessionName) {
return new SessionTypeCheckResult(true, true, pdaSessionType, sessionName == null ? "" : sessionName);
}
static SessionTypeCheckResult mismatch(int pdaSessionType, String sessionName) {
return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName);
}
}
} }

View File

@ -41,6 +41,12 @@ public class Net_CreateAuthSession_Request extends Net_Request {
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
private String clientInfo; private String clientInfo;
/** Числовой код типа сессии. */
private Integer sessionType;
/** Свободная строка платформы клиента, например Web / Android / ESP32. */
private String clientPlatform;
public String getLogin() { public String getLogin() {
return login; return login;
} }
@ -104,4 +110,20 @@ public class Net_CreateAuthSession_Request extends Net_Request {
public void setClientInfo(String clientInfo) { public void setClientInfo(String clientInfo) {
this.clientInfo = clientInfo; this.clientInfo = clientInfo;
} }
public Integer getSessionType() {
return sessionType;
}
public void setSessionType(Integer sessionType) {
this.sessionType = sessionType;
}
public String getClientPlatform() {
return clientPlatform;
}
public void setClientPlatform(String clientPlatform) {
this.clientPlatform = clientPlatform;
}
} }

View File

@ -52,6 +52,12 @@ public class Net_ListSessions_Response extends Net_Response {
/** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */
private String clientInfoFromRequest; private String clientInfoFromRequest;
/** Числовой код типа сессии. */
private int sessionType;
/** Свободная строка платформы, как её прислал клиент. */
private String clientPlatform;
/** Строка геолокации вида "Country, City" или "unknown". */ /** Строка геолокации вида "Country, City" или "unknown". */
private String geo; private String geo;
@ -84,6 +90,22 @@ public class Net_ListSessions_Response extends Net_Response {
this.clientInfoFromRequest = clientInfoFromRequest; this.clientInfoFromRequest = clientInfoFromRequest;
} }
public int getSessionType() {
return sessionType;
}
public void setSessionType(int sessionType) {
this.sessionType = sessionType;
}
public String getClientPlatform() {
return clientPlatform;
}
public void setClientPlatform(String clientPlatform) {
this.clientPlatform = clientPlatform;
}
public String getGeo() { public String getGeo() {
return geo; return geo;
} }

View File

@ -21,6 +21,12 @@ public class Net_SessionLogin_Request extends Net_Request {
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
private String clientInfo; private String clientInfo;
/** Числовой код типа сессии. */
private Integer sessionType;
/** Свободная строка платформы клиента, например Web / Android / ESP32. */
private String clientPlatform;
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
} }
@ -60,4 +66,20 @@ public class Net_SessionLogin_Request extends Net_Request {
public void setClientInfo(String clientInfo) { public void setClientInfo(String clientInfo) {
this.clientInfo = clientInfo; this.clientInfo = clientInfo;
} }
public Integer getSessionType() {
return sessionType;
}
public void setSessionType(Integer sessionType) {
this.sessionType = sessionType;
}
public String getClientPlatform() {
return clientPlatform;
}
public void setClientPlatform(String clientPlatform) {
this.clientPlatform = clientPlatform;
}
} }

View File

@ -1,2 +1,2 @@
client.version=1.2.179 client.version=1.2.180
server.version=1.2.168 server.version=1.2.169

View File

@ -10,6 +10,13 @@ import {
export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' }; export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' };
function formatSessionType(sessionType) {
if (Number(sessionType) === 100) return 'Homeserver';
if (Number(sessionType) === 50) return 'Wallet';
if (Number(sessionType) === 1) return 'Client';
return `Type ${Number(sessionType) || 0}`;
}
function formatSessionTime(ms) { function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', { return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit', day: '2-digit',
@ -46,6 +53,8 @@ export function render({ navigate, route }) {
details.className = 'card stack'; details.className = 'card stack';
details.innerHTML = ` details.innerHTML = `
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div> <div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
<div><p class="meta-muted">sessionType</p><p>${formatSessionType(session.sessionType)}</p></div>
<div><p class="meta-muted">clientPlatform</p><p>${session.clientPlatform || '-'}</p></div>
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div> <div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div>
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div> <div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div> <div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>

View File

@ -11,6 +11,13 @@ import {
export const pageMeta = { id: 'device-view', title: 'Устройства' }; export const pageMeta = { id: 'device-view', title: 'Устройства' };
function formatSessionType(sessionType) {
if (Number(sessionType) === 100) return 'Homeserver';
if (Number(sessionType) === 50) return 'Wallet';
if (Number(sessionType) === 1) return 'Client';
return `Type ${Number(sessionType) || 0}`;
}
function formatSessionTime(ms) { function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', { return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit', day: '2-digit',
@ -60,6 +67,7 @@ export function render({ navigate }) {
<div class="row" style="align-items:flex-start;"> <div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px; text-align:left;"> <div class="stack" style="gap:4px; text-align:left;">
<strong>${session.clientInfoFromClient || 'unknown client'}</strong> <strong>${session.clientInfoFromClient || 'unknown client'}</strong>
<span class="meta-muted">${formatSessionType(session.sessionType)}${session.clientPlatform ? ` · ${session.clientPlatform}` : ''}</span>
<span class="meta-muted">${session.geo || 'unknown'}</span> <span class="meta-muted">${session.geo || 'unknown'}</span>
</div> </div>
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span> <span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>

View File

@ -53,6 +53,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200; const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1; const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const SESSION_TYPE_CLIENT = 1;
const CONNECTION_SUBTYPES = Object.freeze({ const CONNECTION_SUBTYPES = Object.freeze({
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11. // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
@ -110,6 +111,10 @@ function makeClientInfo() {
return ua.slice(0, 50); return ua.slice(0, 50);
} }
function makeClientPlatform() {
return 'Web';
}
function hexToBytes(hex) { function hexToBytes(hex) {
const clean = String(hex || '').trim().toLowerCase(); const clean = String(hex || '').trim().toLowerCase();
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex'); if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
@ -794,6 +799,8 @@ export class AuthService {
authNonce, authNonce,
deviceKey: keyBundle.devicePair.publicKeyB64, deviceKey: keyBundle.devicePair.publicKeyB64,
signatureB64, signatureB64,
sessionType: SESSION_TYPE_CLIENT,
clientPlatform: makeClientPlatform(),
clientInfo: makeClientInfo(), clientInfo: makeClientInfo(),
}); });
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp); if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
@ -932,6 +939,8 @@ export class AuthService {
sessionKey: sessionMaterial.sessionKey, sessionKey: sessionMaterial.sessionKey,
timeMs, timeMs,
signatureB64, signatureB64,
sessionType: SESSION_TYPE_CLIENT,
clientPlatform: makeClientPlatform(),
clientInfo: makeClientInfo(), clientInfo: makeClientInfo(),
}); });
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp); if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);

View File

@ -309,6 +309,7 @@ SessionRecord
| Значение | Смысл | | Значение | Смысл |
|----------|-------| |----------|-------|
| `1` | Обычная пользовательская сессия. | | `1` | Обычная пользовательская сессия. |
| `50` | Кошелёк пользователя. |
| `100` | Homeserver пользователя. | | `100` | Homeserver пользователя. |
Правила: Правила:

View File

@ -534,7 +534,7 @@ signature = Ed25519(blockchain_private_key, message_hash)
- максимум `64` записей; - максимум `64` записей;
- `sessions_mode` допускает только `1` и `10`; - `sessions_mode` допускает только `1` и `10`;
- `session_type` допускает `1` и `100`; - `session_type` допускает `1`, `50` и `100`;
- `session_version` сейчас только `1`; - `session_version` сейчас только `1`;
- `session_name` должен содержать только `[A-Za-z0-9_]`; - `session_name` должен содержать только `[A-Za-z0-9_]`;
- `session_name` и `session_pub_key` уникальны внутри списка. - `session_name` и `session_pub_key` уникальны внутри списка.