Улучшен вход пользователя в аккаунт

This commit is contained in:
AidarKC
2026-08-28 16:51:14 +04:00
parent f2fdb8195c
commit 357a05f7ec
25 changed files with 821 additions and 270 deletions
@@ -36,6 +36,48 @@ public final class UserAccessServersCurrentDAO {
} }
} }
/**
* Возвращает первый access server пользователя ровно в порядке
* solana_user_pda_current.access_servers_json (access_servers[0]).
*
* В user_access_servers_current порядок массива намеренно не хранится,
* поэтому для auth-routing первичный сервер читается напрямую из PDA snapshot.
*/
public UserAccessServerRouteEntry findPrimaryByUserLogin(String userLogin) throws SQLException {
String sql = """
SELECT
u.login AS user_login,
s.login AS server_login,
s.server_address AS server_url,
s.client_key AS server_client_key
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT BTRIM(a.login_value) AS server_login
FROM jsonb_array_elements_text(
CASE
WHEN BTRIM(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS a(login_value, ord)
WHERE a.ord = 1
) primary_access
JOIN solana_user_pda_current s
ON s.normalized_login = LOWER(primary_access.server_login)
AND s.is_server = TRUE
AND BTRIM(COALESCE(s.server_address, '')) <> ''
WHERE u.normalized_login = LOWER(BTRIM(?))
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, userLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException { public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
String sql = """ String sql = """
SELECT user_login, server_login, server_url, server_client_key SELECT user_login, server_login, server_url, server_client_key
@@ -27,6 +27,11 @@ public class ConnectionContext {
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
// При успешной авторизации отдельно фиксируем отношение пользователя к этому серверу.
public static final int USER_SERVER_SCOPE_NONE = 0;
public static final int USER_SERVER_SCOPE_LOCAL = 1;
public static final int USER_SERVER_SCOPE_REMOTE = 2;
// Полный пользователь из runtime БД (current users / solana_user_pda_current) // Полный пользователь из runtime БД (current users / solana_user_pda_current)
private CurrentUserEntry currentUserEntry; private CurrentUserEntry currentUserEntry;
@@ -73,6 +78,12 @@ public class ConnectionContext {
*/ */
private int authenticationStatus = AUTH_STATUS_NONE; private int authenticationStatus = AUTH_STATUS_NONE;
/**
* LOCAL — этот сервер является первым access server пользователя.
* REMOTE — пользователь криптографически авторизован, но его access server другой.
*/
private int userServerScope = USER_SERVER_SCOPE_NONE;
/** /**
* WebSocket-сессия Jetty для данного подключения. * WebSocket-сессия Jetty для данного подключения.
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту. * Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
@@ -197,6 +208,28 @@ public class ConnectionContext {
return authenticationStatus == AUTH_STATUS_NONE; return authenticationStatus == AUTH_STATUS_NONE;
} }
public int getUserServerScope() {
return userServerScope;
}
public void setUserServerScope(int userServerScope) {
this.userServerScope = userServerScope;
}
public boolean isLocalAuthenticatedUser() {
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_LOCAL;
}
public boolean isRemoteAuthenticatedUser() {
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_REMOTE;
}
public String getUserServerScopeName() {
return userServerScope == USER_SERVER_SCOPE_LOCAL ? "LOCAL"
: userServerScope == USER_SERVER_SCOPE_REMOTE ? "REMOTE"
: "NONE";
}
public void reset() { public void reset() {
currentUserEntry = null; currentUserEntry = null;
activeSessionEntry = null; activeSessionEntry = null;
@@ -209,6 +242,7 @@ public class ConnectionContext {
sessionLoginNonceExpiresAtMs = 0; sessionLoginNonceExpiresAtMs = 0;
authenticationStatus = AUTH_STATUS_NONE; authenticationStatus = AUTH_STATUS_NONE;
userServerScope = USER_SERVER_SCOPE_NONE;
wsSession = null; wsSession = null;
serverConnection = false; serverConnection = false;
remoteServerLogin = null; remoteServerLogin = null;
@@ -222,6 +256,7 @@ public class ConnectionContext {
"login='" + getLogin() + '\'' + "login='" + getLogin() + '\'' +
", sessionId=" + sessionId + ", sessionId=" + sessionId +
", authenticationStatus=" + authenticationStatus + ", authenticationStatus=" + authenticationStatus +
", userServerScope=" + getUserServerScopeName() +
'}'; '}';
} }
} }
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ResolveLoginForAuth_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
@@ -22,6 +23,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_
// --- auth entities --- // --- auth entities ---
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
@@ -155,6 +157,7 @@ public final class JsonHandlerRegistry {
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()), Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth --- // --- auth ---
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()), Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()), Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
@@ -242,6 +245,7 @@ public final class JsonHandlerRegistry {
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class), Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth --- // --- auth ---
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class), Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class), Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
@@ -205,6 +205,22 @@ public final class JsonInboundProcessor {
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200)); ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200));
} }
// REMOTE-пользователь успешно авторизован, но этот сервер не является
// его access server. Такой контекст нужен для realtime/call signaling,
// однако обычные homeserver-операции здесь запрещены централизованно.
if (ctx != null
&& ctx.isRemoteAuthenticatedUser()
&& !RemoteUserOperationPolicy.isAllowed(op)) {
Net_Exception_Response err = NetExceptionResponseFactory.error(
op,
requestId,
403,
"USER_NOT_LOCAL",
"Пользователь авторизован, но относится к другому access server"
);
return writeResponse(err);
}
// 5) Вызываем хэндлер // 5) Вызываем хэндлер
Net_Response response; Net_Response response;
try { try {
@@ -0,0 +1,31 @@
package server.logic.ws_protocol.JSON;
import java.util.Set;
/**
* REMOTE-пользователь полностью авторизован криптографически, но этот сервер
* не является его access server. Поэтому ему разрешён только минимальный набор
* realtime/звонковых операций.
*/
public final class RemoteUserOperationPolicy {
private static final Set<String> ALLOWED = Set.of(
"Ping",
"GetServerInfo",
"GetCallIceConfig",
"CallInviteBroadcast",
"CallSignalToSession",
"SendSignal",
"CallDeliveryReport",
"ClientErrorLog",
"ClientDebugLog",
"CloseActiveSession",
"ResolveLoginForAuth"
);
private RemoteUserOperationPolicy() {}
public static boolean isAllowed(String op) {
return op != null && ALLOWED.contains(op);
}
}
@@ -0,0 +1,81 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext;
import shine.db.dao.CurrentUsersDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.sql.SQLException;
import java.util.Locale;
/**
* Единая серверная логика определения, является ли текущий сервер
* домашним access server для пользователя.
*/
public final class AuthAccessScopeSupport {
public static final String RESOLUTION_LOCAL = "LOCAL";
public static final String RESOLUTION_REMOTE = "REMOTE";
public static final String RESOLUTION_NOT_FOUND = "NOT_FOUND";
public static final String RESOLUTION_NO_ACCESS_SERVER = "NO_ACCESS_SERVER";
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private AuthAccessScopeSupport() {}
public static Resolution resolveLogin(String login) throws SQLException {
String cleanLogin = login == null ? "" : login.trim();
if (cleanLogin.isBlank()) {
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
}
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(cleanLogin);
if (user == null) {
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
}
UserAccessServerRouteEntry route =
UserAccessServersCurrentDAO.getInstance().findPrimaryByUserLogin(user.getLogin());
if (route == null) {
return new Resolution(RESOLUTION_NO_ACCESS_SERVER, user, null, null);
}
String ownServerLogin = currentServerLogin();
String routeServerLogin = normalize(route.getServerLogin());
String resolution = ownServerLogin != null && ownServerLogin.equals(routeServerLogin)
? RESOLUTION_LOCAL
: RESOLUTION_REMOTE;
return new Resolution(resolution, user, route, ownServerLogin);
}
public static int connectionScopeForLogin(String login) throws SQLException {
Resolution resolution = resolveLogin(login);
return RESOLUTION_LOCAL.equals(resolution.resolution())
? ConnectionContext.USER_SERVER_SCOPE_LOCAL
: ConnectionContext.USER_SERVER_SCOPE_REMOTE;
}
public static String connectionScopeName(int scope) {
return scope == ConnectionContext.USER_SERVER_SCOPE_LOCAL ? "LOCAL" : "REMOTE";
}
public static String currentServerLogin() {
return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
public record Resolution(
String resolution,
CurrentUserEntry user,
UserAccessServerRouteEntry primaryAccessServer,
String currentServerLogin
) {}
}
@@ -71,6 +71,7 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
} }
ctx.setCurrentUser(solanaUserEntry); ctx.setCurrentUser(solanaUserEntry);
ctx.setUserServerScope(AuthAccessScopeSupport.connectionScopeForLogin(solanaUserEntry.getLogin()));
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
byte[] buf = new byte[32]; byte[] buf = new byte[32];
@@ -414,10 +414,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
// Между AuthChallenge и CreateAuthSession routing мог измениться,
// поэтому окончательный LOCAL/REMOTE scope вычисляем повторно.
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(canonicalLogin);
// --- обновляем контекст --- // --- обновляем контекст ---
ctx.setActiveSession(activeSessionEntry); ctx.setActiveSession(activeSessionEntry);
ctx.setSessionId(sessionId); ctx.setSessionId(sessionId);
ctx.setAuthNonce(null); ctx.setAuthNonce(null);
ctx.setUserServerScope(userServerScope);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx); ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -428,6 +433,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setSessionId(sessionId); resp.setSessionId(sessionId);
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
return resp; return resp;
} }
@@ -0,0 +1,45 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
public class Net_ResolveLoginForAuth_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_ResolveLoginForAuth_Request req = (Net_ResolveLoginForAuth_Request) baseReq;
String login = req.getLogin() == null ? "" : req.getLogin().trim();
if (login.isBlank()) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login");
}
AuthAccessScopeSupport.Resolution resolved = AuthAccessScopeSupport.resolveLogin(login);
Net_ResolveLoginForAuth_Response resp = new Net_ResolveLoginForAuth_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setResolution(resolved.resolution());
CurrentUserEntry user = resolved.user();
if (user != null) {
resp.setLogin(user.getLogin());
}
UserAccessServerRouteEntry route = resolved.primaryAccessServer();
if (route != null) {
resp.setAccessServerLogin(route.getServerLogin());
resp.setAccessServerUrl(route.getServerUrl());
}
return resp;
}
}
@@ -292,10 +292,13 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
session.setClientPlatform(clientPlatform); session.setClientPlatform(clientPlatform);
session.setUserLanguage(userLanguage); session.setUserLanguage(userLanguage);
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(user.getLogin());
// ctx // ctx
ctx.setActiveSession(session); ctx.setActiveSession(session);
ctx.setCurrentUser(user); ctx.setCurrentUser(user);
ctx.setSessionId(sessionId); ctx.setSessionId(sessionId);
ctx.setUserServerScope(userServerScope);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx); ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -306,6 +309,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setStoragePwd(session.getStoragePwd()); resp.setStoragePwd(session.getStoragePwd());
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
return resp; return resp;
} }
@@ -66,6 +66,16 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
} }
String canonicalLogin = user.getLogin(); String canonicalLogin = user.getLogin();
AuthAccessScopeSupport.Resolution authResolution = AuthAccessScopeSupport.resolveLogin(canonicalLogin);
if (!AuthAccessScopeSupport.RESOLUTION_LOCAL.equals(authResolution.resolution())) {
return NetExceptionResponseFactory.error(
req,
409,
"USER_NOT_LOCAL",
"Вход через доверенное устройство нужно начинать на access server пользователя"
);
}
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin); EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
boolean enabled = settings == null || settings.isEnabled(); boolean enabled = settings == null || settings.isEnabled();
if (!enabled) { if (!enabled) {
@@ -100,14 +110,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS; int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
if (approverConnections.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
422,
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
"Нет ни одной активной доверенной сессии пользователя в сети"
);
}
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
canonicalLogin, canonicalLogin,
requesterSessionKey, requesterSessionKey,
@@ -22,6 +22,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
/** Идентификатор сессии, base64 от 32 байт. */ /** Идентификатор сессии, base64 от 32 байт. */
private String sessionId; private String sessionId;
private String connectionScope;
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
@@ -30,4 +31,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
public void setSessionId(String sessionId) { public void setSessionId(String sessionId) {
this.sessionId = sessionId; this.sessionId = sessionId;
} }
public String getConnectionScope() { return connectionScope; }
public void setConnectionScope(String connectionScope) { this.connectionScope = connectionScope; }
} }
@@ -0,0 +1,11 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Первый шаг UI-входа: определить существование пользователя и его access server. */
public class Net_ResolveLoginForAuth_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
@@ -0,0 +1,31 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ ResolveLoginForAuth.
*
* resolution:
* - LOCAL пользователь существует и относится к этому серверу;
* - REMOTE пользователь существует, но его первый access server другой;
* - NOT_FOUND пользователя нет в текущем Solana PDA snapshot;
* - NO_ACCESS_SERVER пользователь существует, но корректный access server не найден.
*/
public class Net_ResolveLoginForAuth_Response extends Net_Response {
private String resolution;
private String login;
private String accessServerLogin;
private String accessServerUrl;
public String getResolution() { return resolution; }
public void setResolution(String resolution) { this.resolution = resolution; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getAccessServerLogin() { return accessServerLogin; }
public void setAccessServerLogin(String accessServerLogin) { this.accessServerLogin = accessServerLogin; }
public String getAccessServerUrl() { return accessServerUrl; }
public void setAccessServerUrl(String accessServerUrl) { this.accessServerUrl = accessServerUrl; }
}
@@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SessionLogin_Response extends Net_Response { public class Net_SessionLogin_Response extends Net_Response {
private String storagePwd; private String storagePwd;
private String connectionScope;
public String getStoragePwd() { public String getStoragePwd() {
return storagePwd; return storagePwd;
@@ -17,4 +18,7 @@ public class Net_SessionLogin_Response extends Net_Response {
public void setStoragePwd(String storagePwd) { public void setStoragePwd(String storagePwd) {
this.storagePwd = storagePwd; this.storagePwd = storagePwd;
} }
public String getConnectionScope() { return connectionScope; }
public void setConnectionScope(String connectionScope) { this.connectionScope = connectionScope; }
} }
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.7.3 client.version=1.7.4
server.version=1.6.1 server.version=1.6.2
+82
View File
@@ -4,6 +4,7 @@
Здесь четыре базовых метода обычной авторизации: Здесь четыре базовых метода обычной авторизации:
- `ResolveLoginForAuth`
- `AuthChallenge` - `AuthChallenge`
- `CreateAuthSession` - `CreateAuthSession`
- `SessionChallenge` - `SessionChallenge`
@@ -67,6 +68,57 @@ ed25519/BASE64_PUBLIC_KEY
--- ---
## 1.1. `ResolveLoginForAuth`
Первый шаг интерактивного входа. Операция не заменяет `GetUser` и не меняет его семантику.
Она предназначена только для выбора правильного access server перед вводом пароля
или запуском входа через доверенное устройство.
Запрос:
```json
{
"op": "ResolveLoginForAuth",
"requestId": "login-route-1",
"payload": {
"login": "alice"
}
}
```
Успешный ответ всегда имеет `status=200`, а `payload.resolution` принимает одно из значений:
- `LOCAL` — пользователь существует, и `access_servers[0]` указывает на этот сервер;
- `REMOTE` — пользователь существует, но его первый access server другой;
- `NOT_FOUND` — пользователя нет в локально синхронизированном Solana PDA snapshot;
- `NO_ACCESS_SERVER` — пользователь существует, но корректный первый access server не найден.
Для `LOCAL` и `REMOTE` сервер возвращает канонический `login`. Для `REMOTE`
также возвращаются `accessServerLogin` и `accessServerUrl`, чтобы UI сразу мог
перенаправить пользователя на правильную точку входа.
Пример `REMOTE`:
```json
{
"op": "ResolveLoginForAuth",
"requestId": "login-route-1",
"status": 200,
"ok": true,
"payload": {
"resolution": "REMOTE",
"login": "Alice",
"accessServerLogin": "shine-node-2",
"accessServerUrl": "wss://node2.example.org/ws"
}
}
```
Важно: это UX-проверка, а не security boundary. `AuthChallenge/CreateAuthSession`
повторно определяют отношение пользователя к текущему серверу. Пользователь другого
access server может криптографически авторизоваться напрямую, но получает
`connectionScope=REMOTE`.
## 2. `AuthChallenge` ## 2. `AuthChallenge`
### Запрос ### Запрос
@@ -339,3 +391,33 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
} }
} }
``` ```
### LOCAL/REMOTE scope успешной авторизации
`CreateAuthSession` и `SessionLogin` дополнительно возвращают:
```json
{
"connectionScope": "LOCAL"
}
```
или:
```json
{
"connectionScope": "REMOTE"
}
```
`REMOTE` означает, что пользователь успешно доказал владение ключом/сессией, но
текущий сервер не является его `access_servers[0]`. Такой WebSocket регистрируется
как активное соединение и может использоваться для realtime звонков, однако
центральная политика сервера разрешает ему только технические и call-signaling
операции. Обычные homeserver-операции отвечают `403 / USER_NOT_LOCAL`.
Разрешённый минимальный набор для `REMOTE`: `Ping`, `GetServerInfo`,
`GetCallIceConfig`, `CallInviteBroadcast`, `CallSignalToSession`, `SendSignal`,
`CallDeliveryReport`, `ClientErrorLog`, `ClientDebugLog`, `CloseActiveSession`
и `ResolveLoginForAuth`.
+6 -1
View File
@@ -313,9 +313,14 @@ TTL заявки фиксирован на сервере и сейчас все
- `400 / BAD_PAYLOAD_TYPE` - `400 / BAD_PAYLOAD_TYPE`
- `422 / PAIRING_NOT_AVAILABLE` - `422 / PAIRING_NOT_AVAILABLE`
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля. - `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся. Код создаётся даже если сейчас нет ни одной онлайн доверённой сессии пользователя.
В таком случае `trustedSessionOnline=false`, а pairing-заявка остаётся в состоянии
`created` до подтверждения, отмены или истечения TTL.
- `429 / PAIRING_RATE_LIMITED` - `429 / PAIRING_RATE_LIMITED`
Также `StartTrustedDeviceLogin` разрешён только на первом access server пользователя.
На другом сервере операция возвращает `409 / USER_NOT_LOCAL`.
### 5.4. `ListTrustedDeviceLoginRequests` ### 5.4. `ListTrustedDeviceLoginRequests`
Доступно для любой уже авторизованной доверенной сессии пользователя. Доступно для любой уже авторизованной доверенной сессии пользователя.
+1
View File
@@ -16,6 +16,7 @@
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара | | `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave | | `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
+60 -51
View File
@@ -73,7 +73,7 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Войти через другое устройство', title: 'Войти через другое устройство',
leftAction: { label: '←', onClick: () => navigate('login-view') }, leftAction: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } },
}), }),
); );
@@ -82,22 +82,17 @@ export function render({ navigate }) {
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>'; panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
const formCard = document.createElement('div'); const formCard = document.createElement('div');
formCard.className = 'card stack'; formCard.className = 'card stack login-device-preparation';
formCard.innerHTML = ` formCard.innerHTML = `
<label class="stack"> <p class="auth-copy" id="pair-login-label"></p>
<span class="field-label">Введите логин</span> <input id="pair-login" type="hidden" value="" />
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" /> <input id="pair-use-password" type="checkbox" hidden />
</label> <label class="stack" id="pair-password-wrap" style="display:none;">
<label class="checkbox-row"> <span class="field-label">Дополнительный пароль подключения</span>
<input type="checkbox" id="pair-use-password" />
использовать доп. пароль
</label>
<label class="stack">
<span class="field-label">Пароль подключения</span>
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" /> <input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
</label> </label>
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button> <button class="primary-btn" type="button" id="pair-start-btn" style="display:none;">Получить код</button>
<p class="meta-muted" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</p> <p class="meta-muted" id="pair-mode-hint">Создаём код для входа</p>
`; `;
const status = document.createElement('p'); const status = document.createElement('p');
@@ -110,10 +105,14 @@ export function render({ navigate }) {
resultWrap.innerHTML = codeCardHtml(); resultWrap.innerHTML = codeCardHtml();
const loginInput = formCard.querySelector('#pair-login'); const loginInput = formCard.querySelector('#pair-login');
const loginLabelEl = formCard.querySelector('#pair-login-label');
const usePasswordInput = formCard.querySelector('#pair-use-password'); const usePasswordInput = formCard.querySelector('#pair-use-password');
const passwordInput = formCard.querySelector('#pair-password'); const passwordInput = formCard.querySelector('#pair-password');
const startBtn = formCard.querySelector('#pair-start-btn'); const startBtn = formCard.querySelector('#pair-start-btn');
const modeHintEl = formCard.querySelector('#pair-mode-hint'); const modeHintEl = formCard.querySelector('#pair-mode-hint');
loginInput.value = String(state.loginDraft.login || '').trim();
loginLabelEl.textContent = loginInput.value ? `Вход для @${loginInput.value}` : '';
const shortCodeEl = resultWrap.querySelector('#pairing-short-code'); const shortCodeEl = resultWrap.querySelector('#pairing-short-code');
const statusHintEl = resultWrap.querySelector('#pairing-status-hint'); const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint'); const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
@@ -127,12 +126,11 @@ export function render({ navigate }) {
const syncPasswordUi = () => { const syncPasswordUi = () => {
const usePassword = !!usePasswordInput.checked; const usePassword = !!usePasswordInput.checked;
passwordInput.parentElement.style.display = usePassword ? '' : 'none'; passwordInput.parentElement.style.display = usePassword ? '' : 'none';
startBtn.style.display = usePassword ? '' : 'none';
modeHintEl.textContent = usePassword modeHintEl.textContent = usePassword
? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.' ? 'На доверённом устройстве для входа по коду включён дополнительный пароль.'
: 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.'; : 'Код можно подтвердить на уже подключённом устройстве. Если оно сейчас не в сети, заявка будет ждать до истечения срока.';
if (!usePassword) { if (!usePassword) passwordInput.value = '';
passwordInput.value = '';
}
}; };
const stopPolling = () => { const stopPolling = () => {
@@ -157,6 +155,8 @@ export function render({ navigate }) {
activePairingId = ''; activePairingId = '';
activeExpiresAtMs = 0; activeExpiresAtMs = 0;
startBtn.disabled = false; startBtn.disabled = false;
startBtn.style.display = '';
startBtn.textContent = 'Получить новый код';
cancelBtn.style.display = 'none'; cancelBtn.style.display = 'none';
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
@@ -310,14 +310,16 @@ export function render({ navigate }) {
setAuthBusy(true); setAuthBusy(true);
setAuthError(''); setAuthError('');
setAuthInfo(''); setAuthInfo('');
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info'); setStatus(status, 'Создаём код для входа…', 'info');
clearActivePairing(); clearActivePairing();
try { try {
await authService.reconnect(state.entrySettings.shineServer); await authService.reconnect(state.entrySettings.shineServer);
const user = await authService.getUser(login); const resolved = await authService.resolveLoginForAuth(login);
if (!user?.exists) { if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
throw new Error('Пользователь не найден.'); state.loginDraft.login = String(resolved?.login || login).trim();
navigate('login-view');
return;
} }
requesterMaterial = await createRequesterPairingMaterial(); requesterMaterial = await createRequesterPairingMaterial();
@@ -339,59 +341,66 @@ export function render({ navigate }) {
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || ''); shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.'; statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
onlineHintEl.textContent = payload?.trustedSessionOnline onlineHintEl.textContent = payload?.trustedSessionOnline
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.' ? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.'; : 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
resultWrap.style.display = ''; resultWrap.style.display = '';
cancelBtn.style.display = '';
startCountdown(payload?.expiresAtMs); startCountdown(payload?.expiresAtMs);
state.loginDraft.login = login; state.loginDraft.login = login;
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info'); setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
schedulePoll(); schedulePoll();
} catch (error) { } catch (error) {
startBtn.disabled = false; startBtn.disabled = false;
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
usePasswordInput.checked = true;
syncPasswordUi();
modeHintEl.textContent = 'Для этого аккаунта включён дополнительный пароль подключения. Введите его, чтобы получить код.';
setStatus(status, 'Введите дополнительный пароль подключения.', 'info');
window.setTimeout(() => passwordInput.focus(), 0);
} else {
startBtn.style.display = '';
startBtn.textContent = 'Повторить';
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.'); const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
setAuthError(message); setAuthError(message);
setStatus(status, message, 'error'); setStatus(status, message, 'error');
}
} finally { } finally {
setAuthBusy(false); setAuthBusy(false);
} }
}); });
cancelBtn.addEventListener('click', async () => { const cancelActivePairingAndBack = async () => {
if (!activePairingId || !requesterMaterial?.sessionKey) { const pairingId = activePairingId;
clearActivePairing(); const requesterSessionKey = requesterMaterial?.sessionKey;
startBtn.disabled = false; isDisposed = true;
cancelBtn.style.display = 'none'; stopPolling();
return; stopCountdown();
} if (pairingId && requesterSessionKey) {
cancelBtn.disabled = true;
try { try {
await authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey); await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey);
clearActivePairing(); } catch {
startBtn.disabled = false; // Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки.
setStatus(status, 'Ожидание подключения отменено.', 'info');
} catch (error) {
const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
cancelBtn.disabled = false;
cancelBtn.style.display = activePairingId ? '' : 'none';
} }
}); }
navigate('login-view');
};
screen.cleanup = () => { screen.cleanup = () => {
isDisposed = true; isDisposed = true;
stopPolling(); stopPolling();
stopCountdown(); stopCountdown();
if (activePairingId && requesterMaterial?.sessionKey) {
void authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey).catch(() => {});
}
}; };
const resultActions = document.createElement('div');
resultActions.className = 'row';
resultActions.append(cancelBtn);
resultWrap.append(resultActions);
panel.append(formCard, status, resultWrap); panel.append(formCard, status, resultWrap);
screen.append(panel); screen.append(panel);
if (!String(loginInput.value || '').trim()) {
window.setTimeout(() => navigate('login-view'), 0);
} else {
// После проверки логина на предыдущем экране код создаётся сразу.
window.setTimeout(() => startBtn.click(), 0);
}
return screen; return screen;
} }
+108 -187
View File
@@ -7,256 +7,177 @@ import {
state, state,
} from '../state.js'; } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
function createWordsLayout({ words, onInput }) { export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div'); function setStatus(statusEl, message) {
grid.className = 'registration-words-grid'; statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Можно вводить любые слова на любых языках. Можно заполнить не все 12 полей. В конце они просто склеиваются в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
} }
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false }; function createSecretOverlay() {
const overlay = document.createElement('div');
overlay.className = 'secret-generation-overlay';
overlay.style.display = 'none';
const card = document.createElement('div');
card.className = 'secret-generation-card stack';
const spinner = document.createElement('div');
spinner.className = 'secret-generation-spinner';
spinner.setAttribute('aria-hidden', 'true');
const title = document.createElement('div');
title.className = 'secret-generation-title';
title.textContent = 'Генерируем секрет…';
const progress = document.createElement('div');
progress.className = 'secret-generation-progress';
progress.textContent = '';
card.append(spinner, title, progress);
overlay.append(card);
return { overlay, progress };
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack auth-screen auth-screen--lower';
clearAuthMessages(); clearAuthMessages();
const form = document.createElement('div'); const login = String(state.loginDraft.login || '').trim();
form.className = 'card stack'; if (!login) {
window.setTimeout(() => navigate('login-view'), 0);
}
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; const panel = document.createElement('section');
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords); panel.className = 'login-panel stack';
const loginInput = document.createElement('input'); const title = document.createElement('h1');
loginInput.className = 'input'; title.className = 'login-panel-title';
loginInput.type = 'text'; title.textContent = 'Введите пароль';
loginInput.autocomplete = 'off';
loginInput.autocapitalize = 'off'; const passwordField = document.createElement('label');
loginInput.spellcheck = false; passwordField.className = 'stack';
loginInput.value = state.loginDraft.login;
loginInput.placeholder = 'Введите логин';
const passwordInput = document.createElement('input'); const passwordInput = document.createElement('input');
passwordInput.className = 'input'; passwordInput.className = 'input';
passwordInput.type = 'password'; passwordInput.type = 'password';
passwordInput.name = 'shine-login-password'; passwordInput.name = 'shine-login-password';
passwordInput.autocomplete = 'new-password'; passwordInput.autocomplete = 'current-password';
passwordInput.autocapitalize = 'off'; passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false; passwordInput.spellcheck = false;
passwordInput.maxLength = PASSWORD_MAX_LENGTH; passwordInput.maxLength = PASSWORD_MAX_LENGTH;
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : ''; passwordInput.placeholder = 'Пароль';
passwordInput.placeholder = 'Введите пароль'; passwordInput.value = '';
const { passwordField.append(passwordInput);
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
syncDraftState();
updateWordsPreview();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
const status = document.createElement('p'); const status = document.createElement('p');
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.style.display = 'none'; status.style.display = 'none';
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'status-line';
function getCurrentPassword() {
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
}
function syncDraftState() {
state.loginDraft.login = loginInput.value.trim();
state.loginDraft.passwordMode = passwordMode;
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
state.loginDraft.password = getCurrentPassword();
}
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
updateWordsPreview();
}
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
`;
form.children[0].append(loginInput);
passwordField = form.children[1];
passwordField.append(passwordInput);
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
updatePasswordModeVisibility();
syncDraftState();
loginInput.addEventListener('input', syncDraftState);
passwordInput.addEventListener('input', () => {
syncDraftState();
updateWordsPreview();
});
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
updateWordsPreview();
syncDraftState();
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const enterButton = document.createElement('button'); const enterButton = document.createElement('button');
enterButton.className = 'primary-btn'; enterButton.className = 'primary-btn';
enterButton.type = 'button'; enterButton.type = 'button';
enterButton.textContent = 'Войти'; enterButton.textContent = 'Войти';
enterButton.addEventListener('click', async () => {
status.style.display = 'none';
syncDraftState();
if (!state.loginDraft.login) { const actions = document.createElement('div');
status.textContent = 'Введите логин.'; actions.className = 'auth-footer-actions';
status.style.display = ''; actions.append(enterButton);
const { overlay, progress } = createSecretOverlay();
const submit = async () => {
const currentLogin = String(state.loginDraft.login || '').trim();
const password = String(passwordInput.value || '');
setStatus(status, '');
if (!currentLogin) {
navigate('login-view');
return; return;
} }
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) { if (password.length > PASSWORD_MAX_LENGTH) {
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`; setStatus(status, `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`);
status.style.display = '';
return; return;
} }
state.loginDraft.password = password;
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = emptyPasswordWords();
setAuthBusy(true); setAuthBusy(true);
setAuthError(''); setAuthError('');
enterButton.disabled = true; enterButton.disabled = true;
enterButton.textContent = 'Входим...';
try { try {
await authService.reconnect(state.entrySettings.shineServer); await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
// Повторная проверка защищает UI от смены access server между первым экраном и входом.
const resolved = await authService.resolveLoginForAuth(currentLogin);
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
state.loginDraft.login = String(resolved?.login || currentLogin).trim();
navigate('login-view');
return;
}
overlay.style.display = 'grid';
const keyBundle = await authService.derivePasswordKeyBundle(currentLogin, password, {
onProgress: ({ percent }) => {
const value = Math.max(0, Math.min(100, Number(percent) || 0));
progress.textContent = value > 0 && value < 100 ? `${Math.round(value)}%` : '';
},
});
overlay.style.display = 'none';
enterButton.textContent = 'Проверяем пароль…';
const result = await authService.createAuthSession(currentLogin, keyBundle);
// Существующую дальнейшую логику после успешного пароля сохраняем без изменений.
state.registrationDraft.flowType = 'login'; state.registrationDraft.flowType = 'login';
state.registrationDraft.login = result.login; state.registrationDraft.login = result.login;
state.registrationDraft.password = state.loginDraft.password; state.registrationDraft.password = password;
state.registrationDraft.passwordMode = state.loginDraft.passwordMode; state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords); state.registrationDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.sessionId = result.sessionId; state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd; state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = result.keyBundle; state.registrationDraft.pendingKeyBundle = keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial; state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
navigate('registration-keys-view'); navigate('registration-keys-view');
} catch (error) { } catch (error) {
overlay.style.display = 'none';
const message = toUserMessage(error, 'Не удалось выполнить вход.'); const message = toUserMessage(error, 'Не удалось выполнить вход.');
setAuthError(message); setAuthError(message);
status.textContent = message; setStatus(status, message);
status.style.display = ''; passwordInput.select();
} finally { } finally {
setAuthBusy(false); setAuthBusy(false);
enterButton.disabled = false; enterButton.disabled = false;
enterButton.textContent = 'Войти'; enterButton.textContent = 'Войти';
} }
};
enterButton.addEventListener('click', submit);
passwordInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void submit();
}
}); });
actions.append(backButton, enterButton); panel.append(title, passwordField, status, actions);
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Войти по логину', title: '',
leftAction: { label: '←', onClick: () => navigate('start-view') }, leftAction: { label: '←', onClick: () => navigate('login-view') },
}), }),
form, panel,
actions, overlay,
); );
window.setTimeout(() => passwordInput.focus(), 0);
return screen; return screen;
} }
+143 -17
View File
@@ -1,31 +1,156 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import {
authService,
clearAuthMessages,
setAuthBusy,
setAuthError,
state,
} from '../state.js';
import { buildShineHttpUrlFromAddress } from '../services/shine-server-resolver.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false }; export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false };
function setStatus(statusEl, message, kind = 'error') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
statusEl.classList.toggle('is-available', kind !== 'error');
statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
}
function setRemoteServer(remoteWrap, serverLoginEl, serverLinkEl, payload) {
const serverLogin = String(payload?.accessServerLogin || '').trim();
const serverUrl = String(payload?.accessServerUrl || '').trim();
serverLoginEl.textContent = serverLogin ? `@${serverLogin}` : 'другой сервер доступа';
serverLinkEl.textContent = serverUrl || 'Открыть сервер';
serverLinkEl.href = buildShineHttpUrlFromAddress(serverUrl);
remoteWrap.style.display = '';
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen'; screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
const loginButton = document.createElement('button'); clearAuthMessages();
loginButton.className = 'ghost-btn';
loginButton.type = 'button';
loginButton.textContent = 'Войти по паролю';
loginButton.addEventListener('click', () => navigate('login-password-view'));
const otherDeviceButton = document.createElement('button');
otherDeviceButton.className = 'ghost-btn';
otherDeviceButton.type = 'button';
otherDeviceButton.textContent = 'Войти через другое устройство';
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
const actions = document.createElement('div');
actions.className = 'auth-actions login-actions-wide';
actions.append(loginButton, otherDeviceButton);
const panel = document.createElement('section'); const panel = document.createElement('section');
panel.className = 'login-panel stack'; panel.className = 'login-panel stack';
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
panel.append(actions); const title = document.createElement('h1');
title.className = 'login-panel-title';
title.textContent = 'Введите логин';
const loginField = document.createElement('label');
loginField.className = 'stack';
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
loginInput.autocomplete = 'username';
loginInput.autocapitalize = 'off';
loginInput.spellcheck = false;
loginInput.placeholder = 'Логин';
loginInput.value = String(state.loginDraft.login || '');
loginField.append(loginInput);
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const remoteWrap = document.createElement('div');
remoteWrap.className = 'login-remote-server stack';
remoteWrap.style.display = 'none';
const remoteText = document.createElement('p');
remoteText.className = 'auth-copy';
const serverLoginEl = document.createElement('strong');
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
const serverLinkEl = document.createElement('a');
serverLinkEl.className = 'primary-btn login-server-link';
serverLinkEl.target = '_self';
serverLinkEl.rel = 'noopener';
remoteWrap.append(remoteText, serverLinkEl);
const passwordButton = document.createElement('button');
passwordButton.className = 'primary-btn';
passwordButton.type = 'button';
passwordButton.textContent = 'Войти по паролю';
const deviceButton = document.createElement('button');
deviceButton.className = 'ghost-btn';
deviceButton.type = 'button';
deviceButton.textContent = 'Войти через другое устройство';
const actions = document.createElement('div');
actions.className = 'auth-actions login-actions-wide';
actions.append(passwordButton, deviceButton);
const resolveAndContinue = async (targetPage) => {
const login = String(loginInput.value || '').trim();
setStatus(status, '');
remoteWrap.style.display = 'none';
if (!login) {
setStatus(status, 'Введите логин.');
loginInput.focus();
return;
}
passwordButton.disabled = true;
deviceButton.disabled = true;
setAuthBusy(true);
setAuthError('');
try {
await authService.reconnect(state.entrySettings.shineServer);
const resolved = await authService.resolveLoginForAuth(login);
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
if (resolution === 'NOT_FOUND') {
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
return;
}
if (resolution === 'NO_ACCESS_SERVER') {
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
return;
}
if (resolution === 'REMOTE') {
state.loginDraft.login = String(resolved?.login || login).trim();
setRemoteServer(remoteWrap, serverLoginEl, serverLinkEl, resolved);
return;
}
if (resolution !== 'LOCAL') {
setStatus(status, 'Сервер вернул неизвестный статус проверки логина.');
return;
}
state.loginDraft.login = String(resolved?.login || login).trim();
state.loginDraft.password = '';
navigate(targetPage);
} catch (error) {
const message = toUserMessage(error, 'Не удалось проверить логин.');
setAuthError(message);
setStatus(status, message);
} finally {
setAuthBusy(false);
passwordButton.disabled = false;
deviceButton.disabled = false;
}
};
passwordButton.addEventListener('click', () => resolveAndContinue('login-password-view'));
deviceButton.addEventListener('click', () => resolveAndContinue('login-other-device-view'));
loginInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void resolveAndContinue('login-password-view');
}
});
panel.append(title, loginField, status, remoteWrap, actions);
screen.append( screen.append(
renderHeader({ renderHeader({
@@ -35,5 +160,6 @@ export function render({ navigate }) {
panel, panel,
); );
window.setTimeout(() => loginInput.focus(), 0);
return screen; return screen;
} }
+12
View File
@@ -1042,6 +1042,14 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async resolveLoginForAuth(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const response = await this.ws.request('ResolveLoginForAuth', { login: cleanLogin });
if (response.status !== 200) throw opError('ResolveLoginForAuth', response);
return response.payload || {};
}
async resolveCanonicalDisplayLogin(login) { async resolveCanonicalDisplayLogin(login) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) return ''; if (!cleanLogin) return '';
@@ -1158,6 +1166,7 @@ export class AuthService {
const sessionId = createResp?.payload?.sessionId; const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId'); if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const connectionScope = String(createResp?.payload?.connectionScope || '').trim().toUpperCase();
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin); const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
@@ -1165,6 +1174,7 @@ export class AuthService {
login: canonicalLogin, login: canonicalLogin,
sessionId, sessionId,
storagePwd, storagePwd,
connectionScope,
sessionMaterial: { sessionMaterial: {
sessionId, sessionId,
sessionKey, sessionKey,
@@ -1322,6 +1332,7 @@ export class AuthService {
const storagePwd = loginResp?.payload?.storagePwd; const storagePwd = loginResp?.payload?.storagePwd;
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd'); if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
const connectionScope = String(loginResp?.payload?.connectionScope || '').trim().toUpperCase();
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin); const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
@@ -1329,6 +1340,7 @@ export class AuthService {
login: canonicalLogin, login: canonicalLogin,
sessionId: targetSessionId, sessionId: targetSessionId,
storagePwd, storagePwd,
connectionScope,
}; };
} }
+8
View File
@@ -50,6 +50,14 @@ export function toUserMessage(error, fallback = 'Действие не выпо
return 'Пользователь не найден. Проверьте логин.'; return 'Пользователь не найден. Проверьте логин.';
} }
if (code === 'DEVICE_KEY_NOT_ACTUAL') {
return 'Неверный пароль.';
}
if (code === 'USER_NOT_LOCAL') {
return 'Этот пользователь относится к другому серверу доступа.';
}
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') { if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.'; return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
} }
+70
View File
@@ -9938,3 +9938,73 @@ body.chat-topbar-overlay .composer-slot {
grid-template-columns: 50px minmax(0, 1fr) auto; grid-template-columns: 50px minmax(0, 1fr) auto;
} }
} }
/* Новый двухшаговый login flow */
.login-remote-server {
width: 100%;
padding: 14px;
border: 1px solid rgba(182, 201, 235, 0.18);
border-radius: 14px;
background: rgba(4, 11, 25, 0.44);
}
.login-server-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
overflow-wrap: anywhere;
text-decoration: none;
}
.login-device-preparation {
width: 100%;
}
.secret-generation-overlay {
position: fixed;
inset: 0;
z-index: 12000;
place-items: center;
padding: 24px;
background: rgba(1, 5, 14, 0.72);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.secret-generation-card {
width: min(100%, 290px);
place-items: center;
padding: 28px 24px;
border: 1px solid rgba(205, 220, 246, 0.18);
border-radius: 20px;
background: rgba(7, 15, 31, 0.9);
box-shadow: 0 22px 54px rgba(0, 0, 0, 0.34);
text-align: center;
}
.secret-generation-spinner {
width: 42px;
height: 42px;
border: 3px solid rgba(220, 232, 255, 0.18);
border-top-color: rgba(238, 203, 126, 0.95);
border-radius: 50%;
animation: secret-generation-spin 0.82s linear infinite;
}
.secret-generation-title {
color: var(--preauth-ivory, #f4ebdd);
font-size: 18px;
font-weight: 650;
}
.secret-generation-progress {
min-height: 20px;
color: var(--preauth-muted, #a8bcdf);
font-size: 13px;
}
@keyframes secret-generation-spin {
to { transform: rotate(360deg); }
}