API сессий: добавить sessionType и clientPlatform
This commit is contained in:
parent
3b8ea70d3c
commit
919387f581
@ -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. Поток авторизации
|
||||
@ -94,6 +111,8 @@ ed25519/BASE64_PUBLIC_KEY
|
||||
"authNonce": "nonce",
|
||||
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
|
||||
"signatureB64": "BASE64_SIGNATURE",
|
||||
"sessionType": 1,
|
||||
"clientPlatform": "Web",
|
||||
"clientInfo": "Android 15; Pixel 9"
|
||||
}
|
||||
}
|
||||
@ -153,6 +172,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
|
||||
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`.
|
||||
- `422 / DEVICE_KEY_NOT_ACTUAL` — `deviceKey` не совпадает с актуальной версией на сервере.
|
||||
- `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` — ошибка БД при создании записи активной сессии.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
@ -208,6 +229,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
|
||||
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||
"timeMs": 1774600010456,
|
||||
"signatureB64": "BASE64_SIGNATURE",
|
||||
"sessionType": 1,
|
||||
"clientPlatform": "Web",
|
||||
"clientInfo": "Android 15; Pixel 9"
|
||||
}
|
||||
}
|
||||
@ -258,6 +281,8 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
||||
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером.
|
||||
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`.
|
||||
- `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` — ошибка БД при чтении пользователя для этой сессии.
|
||||
- `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "sess_7c5e5c4b",
|
||||
"sessionType": 1,
|
||||
"clientPlatform": "Web",
|
||||
"clientInfoFromClient": "Android 15; Pixel 9",
|
||||
"clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1",
|
||||
"geo": "RU/Moscow",
|
||||
@ -58,6 +60,19 @@
|
||||
- `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий.
|
||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
||||
|
||||
### Поля одной сессии в `ListSessions`
|
||||
|
||||
- `sessionId` — идентификатор активной сессии;
|
||||
- `sessionType` — числовой код типа сессии:
|
||||
- `1` — клиент;
|
||||
- `50` — кошелёк;
|
||||
- `100` — homeserver;
|
||||
- `clientPlatform` — строка платформы, как её прислал клиент;
|
||||
- `clientInfoFromClient` — краткая строка клиента;
|
||||
- `clientInfoFromRequest` — строка, собранная сервером из запроса;
|
||||
- `geo` — страна/город или fallback-строка;
|
||||
- `lastAuthenticatedAtMs` — время последней успешной авторизации этой сессии.
|
||||
|
||||
---
|
||||
|
||||
## 2. `CloseActiveSession`
|
||||
|
||||
@ -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
|
||||
@ -178,6 +178,8 @@ public final class DatabaseInitializer {
|
||||
client_ip TEXT,
|
||||
client_info_from_client TEXT,
|
||||
client_info_from_request TEXT,
|
||||
session_type INTEGER NOT NULL DEFAULT 1,
|
||||
client_platform TEXT NOT NULL DEFAULT '',
|
||||
user_language TEXT,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
|
||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
||||
public final class SqliteDbController {
|
||||
|
||||
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;
|
||||
|
||||
@ -86,6 +86,7 @@ public final class SqliteDbController {
|
||||
case 1 -> migrateToV1();
|
||||
case 2 -> migrateToV2();
|
||||
case 3 -> migrateToV3();
|
||||
case 4 -> migrateToV4();
|
||||
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 {
|
||||
st.executeUpdate("""
|
||||
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 {
|
||||
try (var ps = c.prepareStatement("""
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
|
||||
@ -47,8 +47,10 @@ public final class ActiveSessionsDAO {
|
||||
client_ip,
|
||||
client_info_from_client,
|
||||
client_info_from_request,
|
||||
session_type,
|
||||
client_platform,
|
||||
user_language
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@ -64,7 +66,9 @@ public final class ActiveSessionsDAO {
|
||||
ps.setString(10, session.getClientIp());
|
||||
ps.setString(11, session.getClientInfoFromClient());
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -92,6 +96,8 @@ public final class ActiveSessionsDAO {
|
||||
client_ip,
|
||||
client_info_from_client,
|
||||
client_info_from_request,
|
||||
session_type,
|
||||
client_platform,
|
||||
user_language
|
||||
FROM active_sessions
|
||||
WHERE session_id = ?
|
||||
@ -127,6 +133,8 @@ public final class ActiveSessionsDAO {
|
||||
client_ip,
|
||||
client_info_from_client,
|
||||
client_info_from_request,
|
||||
session_type,
|
||||
client_platform,
|
||||
user_language
|
||||
FROM active_sessions
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
@ -179,6 +187,8 @@ public final class ActiveSessionsDAO {
|
||||
String clientIp,
|
||||
String clientInfoFromClient,
|
||||
String clientInfoFromRequest,
|
||||
int sessionType,
|
||||
String clientPlatform,
|
||||
String userLanguage
|
||||
) throws SQLException {
|
||||
|
||||
@ -189,6 +199,8 @@ public final class ActiveSessionsDAO {
|
||||
client_ip = ?,
|
||||
client_info_from_client = ?,
|
||||
client_info_from_request = ?,
|
||||
session_type = ?,
|
||||
client_platform = ?,
|
||||
user_language = ?
|
||||
WHERE session_id = ?
|
||||
""";
|
||||
@ -198,8 +210,10 @@ public final class ActiveSessionsDAO {
|
||||
ps.setString(2, clientIp);
|
||||
ps.setString(3, clientInfoFromClient);
|
||||
ps.setString(4, clientInfoFromRequest);
|
||||
ps.setString(5, userLanguage);
|
||||
ps.setString(6, sessionId);
|
||||
ps.setInt(5, sessionType);
|
||||
ps.setString(6, clientPlatform);
|
||||
ps.setString(7, userLanguage);
|
||||
ps.setString(8, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
@ -210,10 +224,12 @@ public final class ActiveSessionsDAO {
|
||||
String clientIp,
|
||||
String clientInfoFromClient,
|
||||
String clientInfoFromRequest,
|
||||
int sessionType,
|
||||
String clientPlatform,
|
||||
String userLanguage
|
||||
) throws SQLException {
|
||||
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 clientInfoFromClient = rs.getString("client_info_from_client");
|
||||
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");
|
||||
|
||||
return new ActiveSessionEntry(
|
||||
@ -283,6 +301,8 @@ public final class ActiveSessionsDAO {
|
||||
clientIp,
|
||||
clientInfoFromClient,
|
||||
clientInfoFromRequest,
|
||||
sessionType,
|
||||
clientPlatform,
|
||||
userLanguage
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,6 +22,8 @@ public class ActiveSessionEntry {
|
||||
private String clientIp;
|
||||
private String clientInfoFromClient;
|
||||
private String clientInfoFromRequest;
|
||||
private int sessionType;
|
||||
private String clientPlatform;
|
||||
private String userLanguage;
|
||||
|
||||
public ActiveSessionEntry() { }
|
||||
@ -38,6 +40,8 @@ public class ActiveSessionEntry {
|
||||
String clientIp,
|
||||
String clientInfoFromClient,
|
||||
String clientInfoFromRequest,
|
||||
int sessionType,
|
||||
String clientPlatform,
|
||||
String userLanguage) {
|
||||
this.sessionId = sessionId;
|
||||
this.login = login;
|
||||
@ -51,6 +55,8 @@ public class ActiveSessionEntry {
|
||||
this.clientIp = clientIp;
|
||||
this.clientInfoFromClient = clientInfoFromClient;
|
||||
this.clientInfoFromRequest = clientInfoFromRequest;
|
||||
this.sessionType = sessionType;
|
||||
this.clientPlatform = clientPlatform;
|
||||
this.userLanguage = userLanguage;
|
||||
}
|
||||
|
||||
@ -90,6 +96,12 @@ public class ActiveSessionEntry {
|
||||
public String getClientInfoFromRequest() { return 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 void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,6 +213,19 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
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();
|
||||
if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) {
|
||||
Net_Response err = NetExceptionResponseFactory.error(
|
||||
@ -315,6 +328,35 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
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 ---
|
||||
String sessionId = generateRandom32B64Url();
|
||||
long now = System.currentTimeMillis();
|
||||
@ -356,6 +398,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
clientIp,
|
||||
clientInfoFromClient,
|
||||
clientInfoFromRequest,
|
||||
requestedSessionType,
|
||||
clientPlatform,
|
||||
userLanguage
|
||||
);
|
||||
|
||||
|
||||
@ -66,6 +66,8 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
|
||||
info.setSessionId(s.getSessionId());
|
||||
info.setClientInfoFromClient(s.getClientInfoFromClient());
|
||||
info.setClientInfoFromRequest(s.getClientInfoFromRequest());
|
||||
info.setSessionType(s.getSessionType());
|
||||
info.setClientPlatform(s.getClientPlatform());
|
||||
info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs());
|
||||
|
||||
String ip = s.getClientIp();
|
||||
|
||||
@ -216,6 +216,16 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
if (clientInfoFromClient != null && clientInfoFromClient.length() > 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 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();
|
||||
try {
|
||||
ActiveSessionsDAO.getInstance().updateOnRefresh(
|
||||
@ -243,6 +278,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
clientIp,
|
||||
clientInfoFromClient,
|
||||
clientInfoFromRequest,
|
||||
requestedSessionType,
|
||||
clientPlatform,
|
||||
userLanguage
|
||||
);
|
||||
} catch (SQLException e) {
|
||||
@ -253,6 +290,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
session.setClientIp(clientIp);
|
||||
session.setClientInfoFromClient(clientInfoFromClient);
|
||||
session.setClientInfoFromRequest(clientInfoFromRequest);
|
||||
session.setSessionType(requestedSessionType);
|
||||
session.setClientPlatform(clientPlatform);
|
||||
session.setUserLanguage(userLanguage);
|
||||
|
||||
// ctx
|
||||
|
||||
@ -14,7 +14,10 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
||||
@ -57,6 +60,28 @@ public final class SolanaUserPdaImportService {
|
||||
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 {
|
||||
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
|
||||
String lenB58 = toBase58(new byte[]{(byte) login.length()});
|
||||
@ -135,6 +160,7 @@ public final class SolanaUserPdaImportService {
|
||||
byte[] blockchainKey32 = null;
|
||||
byte[] deviceKey32 = null;
|
||||
long paidLimitBytes = 0L;
|
||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
int blockType = u8(raw, c++);
|
||||
@ -196,11 +222,19 @@ public final class SolanaUserPdaImportService {
|
||||
int sessionsCount = u8(raw, c++);
|
||||
if (sessionsCount > 64) return null;
|
||||
for (int j = 0; j < sessionsCount; j++) {
|
||||
c += 1; // session_type
|
||||
c += 1; // session_version
|
||||
int sessionType = u8(raw, c++);
|
||||
int sessionVersion = u8(raw, c++);
|
||||
int n = u8(raw, c++);
|
||||
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
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) {
|
||||
c += 1;
|
||||
@ -217,7 +251,8 @@ public final class SolanaUserPdaImportService {
|
||||
blockchainName,
|
||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||
Base64.getEncoder().encodeToString(deviceKey32),
|
||||
paidLimitBytes
|
||||
paidLimitBytes,
|
||||
sessions
|
||||
);
|
||||
}
|
||||
|
||||
@ -225,7 +260,7 @@ public final class SolanaUserPdaImportService {
|
||||
if (login == null) return null;
|
||||
String s = login.trim();
|
||||
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; }
|
||||
@ -272,11 +307,45 @@ public final class SolanaUserPdaImportService {
|
||||
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(
|
||||
String login,
|
||||
String blockchainName,
|
||||
String blockchainKeyB64,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,12 @@ public class Net_CreateAuthSession_Request extends Net_Request {
|
||||
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
|
||||
private String clientInfo;
|
||||
|
||||
/** Числовой код типа сессии. */
|
||||
private Integer sessionType;
|
||||
|
||||
/** Свободная строка платформы клиента, например Web / Android / ESP32. */
|
||||
private String clientPlatform;
|
||||
|
||||
public String getLogin() {
|
||||
return login;
|
||||
}
|
||||
@ -104,4 +110,20 @@ public class Net_CreateAuthSession_Request extends Net_Request {
|
||||
public void setClientInfo(String 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,12 @@ public class Net_ListSessions_Response extends Net_Response {
|
||||
/** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */
|
||||
private String clientInfoFromRequest;
|
||||
|
||||
/** Числовой код типа сессии. */
|
||||
private int sessionType;
|
||||
|
||||
/** Свободная строка платформы, как её прислал клиент. */
|
||||
private String clientPlatform;
|
||||
|
||||
/** Строка геолокации вида "Country, City" или "unknown". */
|
||||
private String geo;
|
||||
|
||||
@ -84,6 +90,22 @@ public class Net_ListSessions_Response extends Net_Response {
|
||||
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() {
|
||||
return geo;
|
||||
}
|
||||
|
||||
@ -21,6 +21,12 @@ public class Net_SessionLogin_Request extends Net_Request {
|
||||
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
|
||||
private String clientInfo;
|
||||
|
||||
/** Числовой код типа сессии. */
|
||||
private Integer sessionType;
|
||||
|
||||
/** Свободная строка платформы клиента, например Web / Android / ESP32. */
|
||||
private String clientPlatform;
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
@ -60,4 +66,20 @@ public class Net_SessionLogin_Request extends Net_Request {
|
||||
public void setClientInfo(String 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.179
|
||||
server.version=1.2.168
|
||||
client.version=1.2.180
|
||||
server.version=1.2.169
|
||||
|
||||
@ -10,6 +10,13 @@ import {
|
||||
|
||||
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) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
@ -46,6 +53,8 @@ export function render({ navigate, route }) {
|
||||
details.className = 'card stack';
|
||||
details.innerHTML = `
|
||||
<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">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
|
||||
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>
|
||||
|
||||
@ -11,6 +11,13 @@ import {
|
||||
|
||||
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) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
@ -60,6 +67,7 @@ export function render({ navigate }) {
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<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>
|
||||
</div>
|
||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
|
||||
@ -53,6 +53,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const CHANNEL_TYPE_GROUP = 200;
|
||||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||
const SESSION_TYPE_CLIENT = 1;
|
||||
|
||||
const CONNECTION_SUBTYPES = Object.freeze({
|
||||
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
||||
@ -110,6 +111,10 @@ function makeClientInfo() {
|
||||
return ua.slice(0, 50);
|
||||
}
|
||||
|
||||
function makeClientPlatform() {
|
||||
return 'Web';
|
||||
}
|
||||
|
||||
function hexToBytes(hex) {
|
||||
const clean = String(hex || '').trim().toLowerCase();
|
||||
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
||||
@ -794,6 +799,8 @@ export class AuthService {
|
||||
authNonce,
|
||||
deviceKey: keyBundle.devicePair.publicKeyB64,
|
||||
signatureB64,
|
||||
sessionType: SESSION_TYPE_CLIENT,
|
||||
clientPlatform: makeClientPlatform(),
|
||||
clientInfo: makeClientInfo(),
|
||||
});
|
||||
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
||||
@ -932,6 +939,8 @@ export class AuthService {
|
||||
sessionKey: sessionMaterial.sessionKey,
|
||||
timeMs,
|
||||
signatureB64,
|
||||
sessionType: SESSION_TYPE_CLIENT,
|
||||
clientPlatform: makeClientPlatform(),
|
||||
clientInfo: makeClientInfo(),
|
||||
});
|
||||
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
|
||||
|
||||
@ -309,6 +309,7 @@ SessionRecord
|
||||
| Значение | Смысл |
|
||||
|----------|-------|
|
||||
| `1` | Обычная пользовательская сессия. |
|
||||
| `50` | Кошелёк пользователя. |
|
||||
| `100` | Homeserver пользователя. |
|
||||
|
||||
Правила:
|
||||
|
||||
@ -534,7 +534,7 @@ signature = Ed25519(blockchain_private_key, message_hash)
|
||||
|
||||
- максимум `64` записей;
|
||||
- `sessions_mode` допускает только `1` и `10`;
|
||||
- `session_type` допускает `1` и `100`;
|
||||
- `session_type` допускает `1`, `50` и `100`;
|
||||
- `session_version` сейчас только `1`;
|
||||
- `session_name` должен содержать только `[A-Za-z0-9_]`;
|
||||
- `session_name` и `session_pub_key` уникальны внутри списка.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user