В черновую переделал авторификацию
This commit is contained in:
AidarKC
2025-12-09 19:12:37 +03:00
parent 2b5fa16824
commit 2ed4f6d666
13 changed files with 354 additions and 177 deletions
@@ -6,20 +6,19 @@ import java.util.concurrent.CopyOnWriteArraySet;
/**
* Реестр активных подключений (только авторизованные).
*.
*
* Позволяет:
* - получить ConnectionContext по sessionId;
* - получить все активные подключения пользователя по loginId;
* - удалить подключение при закрытии WebSocket.
*.
*
* найти все подключения пользователя:
* var set = ActiveConnectionsRegistry.getInstance().getByLoginId(loginId);
*.
* найти конкретное подключение по sessionId:
* ConnectionContext ctx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
* Session ws = ctx != null ? ctx.getWsSession() : null;
* var set = ActiveConnectionsRegistry.getInstance().getByLoginId(loginId);
*
* найти конкретное подключение по sessionId:
* ConnectionContext ctx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
* Session ws = ctx != null ? ctx.getWsSession() : null;
*/
public final class ActiveConnectionsRegistry {
private static final ActiveConnectionsRegistry INSTANCE = new ActiveConnectionsRegistry();
@@ -32,8 +31,8 @@ public final class ActiveConnectionsRegistry {
// singleton
}
// sessionId -> ConnectionContext
private final ConcurrentHashMap<Long, ConnectionContext> bySessionId = new ConcurrentHashMap<>();
// sessionId (String) -> ConnectionContext
private final ConcurrentHashMap<String, ConnectionContext> bySessionId = new ConcurrentHashMap<>();
// loginId -> множество ConnectionContext для этого пользователя
private final ConcurrentHashMap<Long, Set<ConnectionContext>> byLoginId = new ConcurrentHashMap<>();
@@ -45,7 +44,7 @@ public final class ActiveConnectionsRegistry {
public void register(ConnectionContext ctx) {
if (ctx == null) return;
Long sessionId = ctx.getSessionId();
String sessionId = ctx.getSessionId();
Long loginId = ctx.getLoginId();
if (sessionId == null || loginId == null) {
@@ -65,7 +64,7 @@ public final class ActiveConnectionsRegistry {
public void remove(ConnectionContext ctx) {
if (ctx == null) return;
Long sessionId = ctx.getSessionId();
String sessionId = ctx.getSessionId();
Long loginId = ctx.getLoginId();
if (sessionId != null) {
@@ -86,7 +85,9 @@ public final class ActiveConnectionsRegistry {
/**
* Удалить подключение по sessionId.
*/
public void removeBySessionId(long sessionId) {
public void removeBySessionId(String sessionId) {
if (sessionId == null) return;
ConnectionContext ctx = bySessionId.remove(sessionId);
if (ctx != null) {
Long loginId = ctx.getLoginId();
@@ -105,7 +106,8 @@ public final class ActiveConnectionsRegistry {
/**
* Получить контекст по sessionId.
*/
public ConnectionContext getBySessionId(long sessionId) {
public ConnectionContext getBySessionId(String sessionId) {
if (sessionId == null) return null;
return bySessionId.get(sessionId);
}
@@ -20,7 +20,14 @@ public class ConnectionContext {
// Активная сессия из БД (active_sessions)
private ActiveSession activeSession;
private Long sessionId;
/**
* Идентификатор сессии — base64-строка от 32 байт.
*/
private String sessionId;
/**
* Временный секрет шага 1, который используется на шаге 2 и хранится в БД.
*/
private String sessionPwd;
private int authenticationStatus = AUTH_STATUS_NONE;
@@ -71,11 +78,11 @@ public class ConnectionContext {
// --- sessionId / sessionPwd ---
public Long getSessionId() {
public String getSessionId() {
return sessionId;
}
public void setSessionId(Long sessionId) {
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
@@ -2,7 +2,36 @@ package server.logic.ws_protocol.JSON.entyties.Auth;
import server.logic.ws_protocol.JSON.entyties.NetRequest;
/**
* Шаг 1 авторизации: запрос выдачи временного пароля сессии (sessionPwd).
*
* Клиент по логину просит сервер сгенерировать случайный секрет sessionPwd,
* который будет использован на втором шаге при подписи.
*
* Формат входящего JSON:
* {
* "op": "AuthSessionNewStep1",
* "requestId": "...",
* "payload": {
* "login": "someLogin"
* }
* }
*
* Формат успешного ответа:
* {
* "op": "AuthSessionNewStep1",
* "requestId": "...",
* "status": 200,
* "payload": {
* "sessionPwd": "base64-строка-от-32-байт"
* }
* }
*/
public class NetAuthSessionNewStep1Request extends NetRequest {
/**
* Логин пользователя, для которого запускается авторизация.
*/
private String login;
public String getLogin() {
@@ -2,7 +2,28 @@ package server.logic.ws_protocol.JSON.entyties.Auth;
import server.logic.ws_protocol.JSON.entyties.NetResponse;
/**
* Ответ на AuthSessionNewStep1.
*
* При успехе сервер возвращает временный секрет sessionPwd,
* который клиент обязан использовать на втором шаге при формировании подписи.
*
* JSON:
* {
* "op": "AuthSessionNewStep1",
* "requestId": "...",
* "status": 200,
* "payload": {
* "sessionPwd": "base64-строка-от-32-байт"
* }
* }
*/
public class NetAuthSessionNewStep1Response extends NetResponse {
/**
* Временный секрет, сгенерированный сервером.
* Строка — это base64-представление 32 случайных байт.
*/
private String sessionPwd;
public String getSessionPwd() {
@@ -3,39 +3,47 @@ package server.logic.ws_protocol.JSON.entyties.Auth;
import server.logic.ws_protocol.JSON.entyties.NetRequest;
/**
* Шаг 2 авторизации: клиент подтверждает владение ключом.
*.
* JSON:
* Шаг 2 авторизации: подтверждение владения ключом и установка сессии.
*
* Клиент:
* 1) получает от сервера sessionPwd на шаге 1;
* 2) генерирует свой StoragePwd (base64 от 32 байт);
* 3) формирует строку для подписи:
* "AUTHORIFICATED:" + timeMs + sessionPwd
* 4) подписывает эту строку своим приватным ключом (pubkey1),
* отправляет подпись и StoragePwd на сервер.
*
* Формат входящего JSON:
* {
* "op": "AuthSessionNewStep2",
* "requestId": "...",
* "loginId": 100211,
* "sigNum": 0, // номер подписи: 0 или 1
* "timeMs": 1733310000000, // время в миллисекундах с 1970-01-01
* "signatureB64": "..." // подпись base64 от строки loginId+timeMs+sessionPwd
* "payload": {
* "storagePwd": "base64-строка-от-32-байт",
* "timeMs": 1733310000000,
* "signatureB64": "base64-подпись-Ed25519"
* }
* }
*
* При успешной проверке подписи сервер создаёт запись в active_sessions
* и возвращает sessionId (base64-строка от 32 байт).
*/
public class NetAuthSessionNewStep2Request extends NetRequest {
private long loginId;
private int sigNum; // 0 или 1
private long timeMs; // миллисекунды с 1970
/** Клиентский пароль для хранения данных (base64 от 32 байт). */
private String storagePwd;
/** Время на стороне клиента (мс с 1970-01-01). */
private long timeMs;
/** Подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + sessionPwd (base64). */
private String signatureB64;
public long getLoginId() {
return loginId;
public String getStoragePwd() {
return storagePwd;
}
public void setLoginId(long loginId) {
this.loginId = loginId;
}
public int getSigNum() {
return sigNum;
}
public void setSigNum(int sigNum) {
this.sigNum = sigNum;
public void setStoragePwd(String storagePwd) {
this.storagePwd = storagePwd;
}
public long getTimeMs() {
@@ -4,26 +4,30 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
/**
* Ответ на AuthSessionNewStep2.
*.
* Успешный JSON:
*
* При успехе сервер создаёт запись в active_sessions
* и возвращает идентификатор сессии sessionId.
*
* JSON:
* {
* "op": "AuthSessionNewStep2",
* "requestId": "...",
* "status": 200,
* "payload": {
* "sessionId": 1234567890
* "sessionId": "base64-строка-от-32-байт"
* }
* }
*/
public class NetAuthSessionNewStep2Response extends NetResponse {
private Long sessionId;
/** Идентификатор сессии, base64 от 32 байт. */
private String sessionId;
public Long getSessionId() {
public String getSessionId() {
return sessionId;
}
public void setSessionId(Long sessionId) {
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
}
@@ -4,23 +4,26 @@ import server.logic.ws_protocol.JSON.entyties.NetRequest;
/**
* Запрос SessionRefresh.
*.
*
* Используется для повторного входа без повторной подписи:
* клиент хранит sessionId и sessionPwd, которые получил на шаге 2.
*
* JSON (payload):
* {
* "sessionId": 123,
* "sessionPwd": "abcd..."
* "sessionId": "base64-id-сессии",
* "sessionPwd": "base64-sessionPwd"
* }
*/
public class NetSessionRefreshRequest extends NetRequest {
private long sessionId;
private String sessionId;
private String sessionPwd;
public long getSessionId() {
public String getSessionId() {
return sessionId;
}
public void setSessionId(long sessionId) {
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
@@ -4,9 +4,30 @@ import server.logic.ws_protocol.JSON.entyties.NetResponse;
/**
* Успешный ответ на SessionRefresh.
*.
* Дополнительных полей нет, достаточно status=200 и (опционально) пустого payload.
*
* Дополнительно к статусу 200 сервер возвращает storagePwd,
* чтобы клиент мог восстановить/синхронизировать локальное хранилище.
*
* JSON:
* {
* "op": "SessionRefresh",
* "requestId": "...",
* "status": 200,
* "payload": {
* "storagePwd": "base64-строка-от-32-байт"
* }
* }
*/
public class NetSessionRefreshResponse extends NetResponse {
// Ничего дополнительного, вся информация в status.
/** Пароль хранилища, сохранённый в сессии (storagePwd). */
private String storagePwd;
public String getStoragePwd() {
return storagePwd;
}
public void setStoragePwd(String storagePwd) {
this.storagePwd = storagePwd;
}
}
@@ -11,6 +11,7 @@ import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SolanaUser;
import java.security.SecureRandom;
import java.util.Base64;
public class NetAuthSessionNewStep1Handler implements JsonMessageHandler {
@@ -57,9 +58,10 @@ public class NetAuthSessionNewStep1Handler implements JsonMessageHandler {
// 3) Заполняем контекст целиком пользователем
ctx.setSolanaUser(solanaUser);
// 4) Генерируем надёжный sessionPwd
String sessionPwd = Long.toHexString(System.nanoTime()) +
Long.toHexString(RANDOM.nextLong());
// 4) Генерируем надёжный sessionPwd = base64(32 случайных байт)
byte[] buf = new byte[32];
RANDOM.nextBytes(buf);
String sessionPwd = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
ctx.setSessionPwd(sessionPwd);
@@ -68,7 +70,7 @@ public class NetAuthSessionNewStep1Handler implements JsonMessageHandler {
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setSessionPwd(sessionPwd); // 🔴 Больше не трогаем payload
resp.setSessionPwd(sessionPwd);
return resp;
}
@@ -18,22 +18,38 @@ import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.concurrent.ThreadLocalRandom;
/**
* Шаг 2 авторизации: проверка подписи и создание сессии.
*.
* Клиент присылает:
* - loginId
* - sigNum (0 или 1)
* - timeMs
* - signatureB64 от строки (loginId + timeMs + sessionPwd)
*
* Клиент присылает в payload:
* - storagePwd (base64 от 32 байт)
* - timeMs (long, мс с 1970-01-01)
* - signatureB64 (подпись Ed25519 над строкой:
* "AUTHORIFICATED:" + timeMs + sessionPwd)
*
* Параметр sessionPwd клиент получил на шаге 1.
* Для проверки подписи используется pubkey1 (второй публичный ключ пользователя).
*
* Дополнительно:
* - timeMs должен отличаться от текущего времени сервера не более чем на 30 секунд.
*
* При успехе:
* - создаётся запись ActiveSession в БД;
* - генерируется sessionId (base64 от 32 случайных байт);
* - sessionCreatedAtMs и lastAuthirificatedAtMs = текущее время;
* - pushEndpoint / pushP256dhKey / pushAuthKey остаются пустыми;
* - возвращается sessionId в ответе.
*/
public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(NetAuthSessionNewStep2Handler.class);
private static final SecureRandom RANDOM = new SecureRandom();
private static final long ALLOWED_SKEW_MS = 30_000L;
@Override
public NetResponse handle(NetRequest baseReq, ConnectionContext ctx) throws Exception {
NetAuthSessionNewStep2Request req = (NetAuthSessionNewStep2Request) baseReq;
@@ -58,25 +74,23 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
}
SolanaUser user = ctx.getSolanaUser();
long reqLoginId = req.getLoginId();
Long ctxLoginId = user.getLoginId();
if (ctxLoginId == null || ctxLoginId != reqLoginId) {
Long loginId = user.getLoginId();
if (loginId == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"LOGIN_ID_MISMATCH",
"loginId в запросе не совпадает с пользователем из шага 1"
WireCodes.Status.SERVER_DATA_ERROR,
"NO_LOGIN_ID",
"Для пользователя не задан loginId в БД"
);
}
int sigNum = req.getSigNum();
if (sigNum != 0 && sigNum != 1) {
String storagePwd = req.getStoragePwd();
if (storagePwd == null || storagePwd.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SIG_NUM",
"Номер подписи должен быть 0 или 1"
"EMPTY_STORAGE_PWD",
"Пустой storagePwd"
);
}
@@ -90,14 +104,28 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
);
}
// --- выбираем публичный ключ по sigNum ---
String pubKeyB64 = (sigNum == 0) ? user.getPubkey0() : user.getPubkey1();
long timeMs = req.getTimeMs();
long nowMs = System.currentTimeMillis();
// Проверка, что время клиента не отличается от времени сервера больше чем на 30 секунд
long diff = Math.abs(nowMs - timeMs);
if (diff > ALLOWED_SKEW_MS) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"TIME_SKEW",
"Время клиента отличается от сервера более чем на 30 секунд"
);
}
// --- выбираем публичный ключ pubkey1 ---
String pubKeyB64 = user.getPubkey1();
if (pubKeyB64 == null || pubKeyB64.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"NO_PUBKEY",
"Отсутствует публичный ключ для выбранного номера подписи"
"NO_PUBKEY1",
"Отсутствует публичный ключ pubkey1 для пользователя"
);
}
@@ -115,9 +143,8 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
);
}
// --- собираем строку для подписи: loginId + timeMs + sessionPwd ---
long timeMs = req.getTimeMs();
String preimageStr = String.valueOf(reqLoginId) + timeMs + ctx.getSessionPwd();
// --- собираем строку для подписи: "AUTHORIFICATED:" + timeMs + sessionPwd ---
String preimageStr = "AUTHORIFICATED:" + timeMs + ctx.getSessionPwd();
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
boolean sigOk = Ed25519Util.verify(preimage, signature64, publicKey32);
@@ -130,29 +157,30 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
);
}
// --- создаём уникальный sessionId и записываем в БД ---
// --- создаём уникальный sessionId (base64 от 32 байт) и записываем в БД ---
ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance();
long sessionId;
String sessionId;
ActiveSession activeSession;
try {
sessionId = generateUniqueSessionId(dao);
long nowMs = System.currentTimeMillis();
sessionId = generateRandomSessionId();
long now = System.currentTimeMillis();
activeSession = new ActiveSession(
sessionId,
loginId,
ctx.getSessionPwd(),
reqLoginId,
nowMs,
(short) sigNum, // pubkeyNum
null, // pushEndpoint
null, // pushP256dhKey
null // pushAuthKey
storagePwd,
now,
now,
null, // pushEndpoint
null, // pushP256dhKey
null // pushAuthKey
);
dao.insert(activeSession);
} catch (SQLException e) {
log.error("Ошибка БД при создании новой сессии для loginId={}", reqLoginId, e);
log.error("Ошибка БД при создании новой сессии для loginId={}", loginId, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
@@ -166,7 +194,6 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
ctx.setSessionId(sessionId);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().removeBySessionId(sessionId); // га всякий случай предварительно удаляем что бы точно небыло дублирования активной сессии
// Регистрируем это подключение в глобальном реестре активных соединений
ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -180,16 +207,11 @@ public class NetAuthSessionNewStep2Handler implements JsonMessageHandler {
}
/**
* Генерация уникального sessionId с проверкой в БД.
* Генерация случайного sessionId: base64-строка от 32 байт.
*/
private long generateUniqueSessionId(ActiveSessionsDAO dao) throws SQLException {
for (int i = 0; i < 10; i++) {
long candidate = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
ActiveSession existing = dao.getBySessionId(candidate);
if (existing == null) {
return candidate;
}
}
throw new SQLException("Не удалось сгенерировать уникальный sessionId за разумное число попыток");
private String generateRandomSessionId() {
byte[] buf = new byte[32];
RANDOM.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
}
@@ -20,6 +20,12 @@ import java.sql.SQLException;
/**
* Хэндлер SessionRefresh.
*
* При успешной проверке sessionId + sessionPwd:
* - подтягивает пользователя по loginId из сессии;
* - заполняет ConnectionContext;
* - обновляет lastAuthirificatedAtMs в БД на текущее время;
* - возвращает storagePwd в payload.
*/
public class NetSessionRefreshHandler implements JsonMessageHandler {
@@ -29,9 +35,18 @@ public class NetSessionRefreshHandler implements JsonMessageHandler {
public NetResponse handle(NetRequest request, ConnectionContext ctx) throws Exception {
NetSessionRefreshRequest req = (NetSessionRefreshRequest) request;
long sessionId = req.getSessionId();
String sessionId = req.getSessionId();
String sessionPwd = req.getSessionPwd();
if (sessionId == null || sessionId.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SESSION_ID",
"Пустой идентификатор сессии"
);
}
if (sessionPwd == null || sessionPwd.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
@@ -76,13 +91,10 @@ public class NetSessionRefreshHandler implements JsonMessageHandler {
// --- достаём пользователя по loginId из сессии ---
SolanaUser solanaUser = null;
Long loginId = null;
long loginId = session.getLoginId();
try {
loginId = session.getLoginId();
if (loginId != null) {
SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
solanaUser = usersDao.getByLoginId(loginId);
}
SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
solanaUser = usersDao.getByLoginId(loginId);
} catch (SQLException e) {
log.error("Ошибка БД при поиске пользователя по loginId={} из сессии", loginId, e);
return NetExceptionResponseFactory.error(
@@ -93,7 +105,7 @@ public class NetSessionRefreshHandler implements JsonMessageHandler {
);
}
if (loginId != null && solanaUser == null) {
if (solanaUser == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
@@ -110,16 +122,24 @@ public class NetSessionRefreshHandler implements JsonMessageHandler {
ctx.setSessionPwd(sessionPwd);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().removeBySessionId(sessionId); // на всякий случай удаляем что бы точно небыло повторов
// Регистрируем это подключение в глобальном реестре активных соединений
ActiveConnectionsRegistry.getInstance().register(ctx);
}
// И возвращаем OK без доп. полей (payload будет {}).
// Обновляем lastAuthirificatedAtMs в БД
try {
long nowMs = System.currentTimeMillis();
sessionsDao.updateLastAuthirificatedAtMs(sessionId, nowMs);
} catch (SQLException e) {
log.error("Ошибка БД при обновлении lastAuthirificatedAtMs для sessionId={}", sessionId, e);
}
// Возвращаем OK + storagePwd
NetSessionRefreshResponse resp = new NetSessionRefreshResponse();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setStoragePwd(session.getStoragePwd());
return resp;
}
}