SHA256
Улучшен вход пользователя в аккаунт
This commit is contained in:
+42
@@ -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
|
||||||
|
|||||||
+35
@@ -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
@@ -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),
|
||||||
|
|||||||
+16
@@ -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 {
|
||||||
|
|||||||
+31
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+1
@@ -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];
|
||||||
|
|||||||
+6
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -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,
|
||||||
|
|||||||
+4
@@ -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; }
|
||||||
}
|
}
|
||||||
+11
@@ -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; }
|
||||||
|
}
|
||||||
+31
@@ -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; }
|
||||||
|
}
|
||||||
+4
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.7.3
|
client.version=1.7.4
|
||||||
server.version=1.6.1
|
server.version=1.6.2
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|||||||
@@ -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 для входа в существующую сессию |
|
||||||
|
|||||||
@@ -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;
|
||||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
|
||||||
setAuthError(message);
|
usePasswordInput.checked = true;
|
||||||
setStatus(status, message, 'error');
|
syncPasswordUi();
|
||||||
|
modeHintEl.textContent = 'Для этого аккаунта включён дополнительный пароль подключения. Введите его, чтобы получить код.';
|
||||||
|
setStatus(status, 'Введите дополнительный пароль подключения.', 'info');
|
||||||
|
window.setTimeout(() => passwordInput.focus(), 0);
|
||||||
|
} else {
|
||||||
|
startBtn.style.display = '';
|
||||||
|
startBtn.textContent = 'Повторить';
|
||||||
|
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||||
|
setAuthError(message);
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey);
|
||||||
|
} catch {
|
||||||
|
// Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cancelBtn.disabled = true;
|
navigate('login-view');
|
||||||
try {
|
};
|
||||||
await authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey);
|
|
||||||
clearActivePairing();
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user