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 {
|
||||
String sql = """
|
||||
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_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)
|
||||
private CurrentUserEntry currentUserEntry;
|
||||
|
||||
@@ -73,6 +78,12 @@ public class ConnectionContext {
|
||||
*/
|
||||
private int authenticationStatus = AUTH_STATUS_NONE;
|
||||
|
||||
/**
|
||||
* LOCAL — этот сервер является первым access server пользователя.
|
||||
* REMOTE — пользователь криптографически авторизован, но его access server другой.
|
||||
*/
|
||||
private int userServerScope = USER_SERVER_SCOPE_NONE;
|
||||
|
||||
/**
|
||||
* WebSocket-сессия Jetty для данного подключения.
|
||||
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
|
||||
@@ -197,6 +208,28 @@ public class ConnectionContext {
|
||||
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() {
|
||||
currentUserEntry = null;
|
||||
activeSessionEntry = null;
|
||||
@@ -209,6 +242,7 @@ public class ConnectionContext {
|
||||
sessionLoginNonceExpiresAtMs = 0;
|
||||
|
||||
authenticationStatus = AUTH_STATUS_NONE;
|
||||
userServerScope = USER_SERVER_SCOPE_NONE;
|
||||
wsSession = null;
|
||||
serverConnection = false;
|
||||
remoteServerLogin = null;
|
||||
@@ -222,6 +256,7 @@ public class ConnectionContext {
|
||||
"login='" + getLogin() + '\'' +
|
||||
", sessionId=" + sessionId +
|
||||
", 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.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_CreateAuthSession__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 ---
|
||||
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_CreateAuthSession_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()),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
|
||||
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
||||
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
|
||||
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
|
||||
@@ -242,6 +245,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
|
||||
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
||||
Map.entry("CreateAuthSession", Net_CreateAuthSession_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));
|
||||
}
|
||||
|
||||
// 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) Вызываем хэндлер
|
||||
Net_Response response;
|
||||
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.setUserServerScope(AuthAccessScopeSupport.connectionScopeForLogin(solanaUserEntry.getLogin()));
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
|
||||
|
||||
byte[] buf = new byte[32];
|
||||
|
||||
+6
@@ -414,10 +414,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
return err;
|
||||
}
|
||||
|
||||
// Между AuthChallenge и CreateAuthSession routing мог измениться,
|
||||
// поэтому окончательный LOCAL/REMOTE scope вычисляем повторно.
|
||||
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(canonicalLogin);
|
||||
|
||||
// --- обновляем контекст ---
|
||||
ctx.setActiveSession(activeSessionEntry);
|
||||
ctx.setSessionId(sessionId);
|
||||
ctx.setAuthNonce(null);
|
||||
ctx.setUserServerScope(userServerScope);
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||
|
||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||
@@ -428,6 +433,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setSessionId(sessionId);
|
||||
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
|
||||
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.setUserLanguage(userLanguage);
|
||||
|
||||
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(user.getLogin());
|
||||
|
||||
// ctx
|
||||
ctx.setActiveSession(session);
|
||||
ctx.setCurrentUser(user);
|
||||
ctx.setSessionId(sessionId);
|
||||
ctx.setUserServerScope(userServerScope);
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||
|
||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||
@@ -306,6 +309,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setStoragePwd(session.getStoragePwd());
|
||||
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -66,6 +66,16 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
||||
}
|
||||
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);
|
||||
boolean enabled = settings == null || settings.isEnabled();
|
||||
if (!enabled) {
|
||||
@@ -100,14 +110,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
||||
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
||||
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
|
||||
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||
if (approverConnections.isEmpty()) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
422,
|
||||
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
|
||||
"Нет ни одной активной доверенной сессии пользователя в сети"
|
||||
);
|
||||
}
|
||||
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||
canonicalLogin,
|
||||
requesterSessionKey,
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
|
||||
|
||||
/** Идентификатор сессии, base64 от 32 байт. */
|
||||
private String sessionId;
|
||||
private String connectionScope;
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
@@ -30,4 +31,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
|
||||
public void setSessionId(String 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 {
|
||||
|
||||
private String storagePwd;
|
||||
private String connectionScope;
|
||||
|
||||
public String getStoragePwd() {
|
||||
return storagePwd;
|
||||
@@ -17,4 +18,7 @@ public class Net_SessionLogin_Response extends Net_Response {
|
||||
public void setStoragePwd(String 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
|
||||
server.version=1.6.1
|
||||
client.version=1.7.4
|
||||
server.version=1.6.2
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
Здесь четыре базовых метода обычной авторизации:
|
||||
|
||||
- `ResolveLoginForAuth`
|
||||
- `AuthChallenge`
|
||||
- `CreateAuthSession`
|
||||
- `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`
|
||||
|
||||
### Запрос
|
||||
@@ -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`
|
||||
- `422 / PAIRING_NOT_AVAILABLE`
|
||||
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
|
||||
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
|
||||
Код создаётся даже если сейчас нет ни одной онлайн доверённой сессии пользователя.
|
||||
В таком случае `trustedSessionOnline=false`, а pairing-заявка остаётся в состоянии
|
||||
`created` до подтверждения, отмены или истечения TTL.
|
||||
- `429 / PAIRING_RATE_LIMITED`
|
||||
|
||||
Также `StartTrustedDeviceLogin` разрешён только на первом access server пользователя.
|
||||
На другом сервере операция возвращает `409 / USER_NOT_LOCAL`.
|
||||
|
||||
### 5.4. `ListTrustedDeviceLoginRequests`
|
||||
|
||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
||||
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
|
||||
| `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 для создания новой сессии |
|
||||
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
||||
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
||||
|
||||
@@ -73,7 +73,7 @@ export function render({ navigate }) {
|
||||
screen.append(
|
||||
renderHeader({
|
||||
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>';
|
||||
|
||||
const formCard = document.createElement('div');
|
||||
formCard.className = 'card stack';
|
||||
formCard.className = 'card stack login-device-preparation';
|
||||
formCard.innerHTML = `
|
||||
<label class="stack">
|
||||
<span class="field-label">Введите логин</span>
|
||||
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="pair-use-password" />
|
||||
использовать доп. пароль
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span class="field-label">Пароль подключения</span>
|
||||
<p class="auth-copy" id="pair-login-label"></p>
|
||||
<input id="pair-login" type="hidden" value="" />
|
||||
<input id="pair-use-password" type="checkbox" hidden />
|
||||
<label class="stack" id="pair-password-wrap" style="display:none;">
|
||||
<span class="field-label">Дополнительный пароль подключения</span>
|
||||
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
||||
</label>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</p>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn" style="display:none;">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Создаём код для входа…</p>
|
||||
`;
|
||||
|
||||
const status = document.createElement('p');
|
||||
@@ -110,10 +105,14 @@ export function render({ navigate }) {
|
||||
resultWrap.innerHTML = codeCardHtml();
|
||||
|
||||
const loginInput = formCard.querySelector('#pair-login');
|
||||
const loginLabelEl = formCard.querySelector('#pair-login-label');
|
||||
const usePasswordInput = formCard.querySelector('#pair-use-password');
|
||||
const passwordInput = formCard.querySelector('#pair-password');
|
||||
const startBtn = formCard.querySelector('#pair-start-btn');
|
||||
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 statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
||||
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
||||
@@ -127,12 +126,11 @@ export function render({ navigate }) {
|
||||
const syncPasswordUi = () => {
|
||||
const usePassword = !!usePasswordInput.checked;
|
||||
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
||||
startBtn.style.display = usePassword ? '' : 'none';
|
||||
modeHintEl.textContent = usePassword
|
||||
? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.'
|
||||
: 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.';
|
||||
if (!usePassword) {
|
||||
passwordInput.value = '';
|
||||
}
|
||||
? 'На доверённом устройстве для входа по коду включён дополнительный пароль.'
|
||||
: 'Код можно подтвердить на уже подключённом устройстве. Если оно сейчас не в сети, заявка будет ждать до истечения срока.';
|
||||
if (!usePassword) passwordInput.value = '';
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -157,6 +155,8 @@ export function render({ navigate }) {
|
||||
activePairingId = '';
|
||||
activeExpiresAtMs = 0;
|
||||
startBtn.disabled = false;
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Получить новый код';
|
||||
cancelBtn.style.display = 'none';
|
||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||
@@ -310,14 +310,16 @@ export function render({ navigate }) {
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
setAuthInfo('');
|
||||
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
|
||||
setStatus(status, 'Создаём код для входа…', 'info');
|
||||
clearActivePairing();
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const user = await authService.getUser(login);
|
||||
if (!user?.exists) {
|
||||
throw new Error('Пользователь не найден.');
|
||||
const resolved = await authService.resolveLoginForAuth(login);
|
||||
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
|
||||
requesterMaterial = await createRequesterPairingMaterial();
|
||||
@@ -339,59 +341,66 @@ export function render({ navigate }) {
|
||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
||||
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
||||
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||
resultWrap.style.display = '';
|
||||
cancelBtn.style.display = '';
|
||||
startCountdown(payload?.expiresAtMs);
|
||||
state.loginDraft.login = login;
|
||||
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
startBtn.disabled = false;
|
||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
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, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', async () => {
|
||||
if (!activePairingId || !requesterMaterial?.sessionKey) {
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
cancelBtn.style.display = 'none';
|
||||
return;
|
||||
const cancelActivePairingAndBack = async () => {
|
||||
const pairingId = activePairingId;
|
||||
const requesterSessionKey = requesterMaterial?.sessionKey;
|
||||
isDisposed = true;
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
if (pairingId && requesterSessionKey) {
|
||||
try {
|
||||
await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey);
|
||||
} catch {
|
||||
// Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки.
|
||||
}
|
||||
}
|
||||
cancelBtn.disabled = true;
|
||||
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';
|
||||
}
|
||||
});
|
||||
navigate('login-view');
|
||||
};
|
||||
|
||||
screen.cleanup = () => {
|
||||
isDisposed = true;
|
||||
stopPolling();
|
||||
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);
|
||||
screen.append(panel);
|
||||
|
||||
if (!String(loginInput.value || '').trim()) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
} else {
|
||||
// После проверки логина на предыдущем экране код создаётся сразу.
|
||||
window.setTimeout(() => startBtn.click(), 0);
|
||||
}
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -7,256 +7,177 @@ import {
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
composePasswordFromWords,
|
||||
emptyPasswordWords,
|
||||
normalizePasswordWords,
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_WORDS_COUNT,
|
||||
} from '../services/password-words.js';
|
||||
import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
|
||||
|
||||
function createWordsLayout({ words, onInput }) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'registration-words-block';
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'registration-words-grid';
|
||||
|
||||
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 };
|
||||
function setStatus(statusEl, message) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = message ? '' : 'none';
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack auth-screen auth-screen--lower';
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'card stack';
|
||||
const login = String(state.loginDraft.login || '').trim();
|
||||
if (!login) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
}
|
||||
|
||||
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
||||
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.autocomplete = 'off';
|
||||
loginInput.autocapitalize = 'off';
|
||||
loginInput.spellcheck = false;
|
||||
loginInput.value = state.loginDraft.login;
|
||||
loginInput.placeholder = 'Введите логин';
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'login-panel-title';
|
||||
title.textContent = 'Введите пароль';
|
||||
|
||||
const passwordField = document.createElement('label');
|
||||
passwordField.className = 'stack';
|
||||
|
||||
const passwordInput = document.createElement('input');
|
||||
passwordInput.className = 'input';
|
||||
passwordInput.type = 'password';
|
||||
passwordInput.name = 'shine-login-password';
|
||||
passwordInput.autocomplete = 'new-password';
|
||||
passwordInput.autocomplete = 'current-password';
|
||||
passwordInput.autocapitalize = 'off';
|
||||
passwordInput.spellcheck = false;
|
||||
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
|
||||
passwordInput.placeholder = 'Введите пароль';
|
||||
passwordInput.placeholder = 'Пароль';
|
||||
passwordInput.value = '';
|
||||
|
||||
const {
|
||||
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 = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
||||
passwordField.append(passwordInput);
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
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');
|
||||
enterButton.className = 'primary-btn';
|
||||
enterButton.type = 'button';
|
||||
enterButton.textContent = 'Войти';
|
||||
enterButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
syncDraftState();
|
||||
|
||||
if (!state.loginDraft.login) {
|
||||
status.textContent = 'Введите логин.';
|
||||
status.style.display = '';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
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;
|
||||
}
|
||||
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
|
||||
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||
status.style.display = '';
|
||||
if (password.length > PASSWORD_MAX_LENGTH) {
|
||||
setStatus(status, `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.loginDraft.password = password;
|
||||
state.loginDraft.passwordMode = 'single';
|
||||
state.loginDraft.passwordWords = emptyPasswordWords();
|
||||
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
enterButton.disabled = true;
|
||||
enterButton.textContent = 'Входим...';
|
||||
|
||||
try {
|
||||
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.login = result.login;
|
||||
state.registrationDraft.password = state.loginDraft.password;
|
||||
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
|
||||
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||
state.registrationDraft.password = password;
|
||||
state.registrationDraft.passwordMode = 'single';
|
||||
state.registrationDraft.passwordWords = emptyPasswordWords();
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingKeyBundle = keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
overlay.style.display = 'none';
|
||||
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
setStatus(status, message);
|
||||
passwordInput.select();
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
enterButton.disabled = false;
|
||||
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(
|
||||
renderHeader({
|
||||
title: 'Войти по логину',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
form,
|
||||
actions,
|
||||
panel,
|
||||
overlay,
|
||||
);
|
||||
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
return screen;
|
||||
}
|
||||
|
||||
+143
-17
@@ -1,31 +1,156 @@
|
||||
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 };
|
||||
|
||||
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 }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
|
||||
|
||||
const loginButton = document.createElement('button');
|
||||
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);
|
||||
clearAuthMessages();
|
||||
|
||||
const panel = document.createElement('section');
|
||||
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(
|
||||
renderHeader({
|
||||
@@ -35,5 +160,6 @@ export function render({ navigate }) {
|
||||
panel,
|
||||
);
|
||||
|
||||
window.setTimeout(() => loginInput.focus(), 0);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1042,6 +1042,14 @@ export class AuthService {
|
||||
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) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return '';
|
||||
@@ -1158,6 +1166,7 @@ export class AuthService {
|
||||
|
||||
const sessionId = createResp?.payload?.sessionId;
|
||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||
const connectionScope = String(createResp?.payload?.connectionScope || '').trim().toUpperCase();
|
||||
|
||||
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
@@ -1165,6 +1174,7 @@ export class AuthService {
|
||||
login: canonicalLogin,
|
||||
sessionId,
|
||||
storagePwd,
|
||||
connectionScope,
|
||||
sessionMaterial: {
|
||||
sessionId,
|
||||
sessionKey,
|
||||
@@ -1322,6 +1332,7 @@ export class AuthService {
|
||||
|
||||
const storagePwd = loginResp?.payload?.storagePwd;
|
||||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||||
const connectionScope = String(loginResp?.payload?.connectionScope || '').trim().toUpperCase();
|
||||
|
||||
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
@@ -1329,6 +1340,7 @@ export class AuthService {
|
||||
login: canonicalLogin,
|
||||
sessionId: targetSessionId,
|
||||
storagePwd,
|
||||
connectionScope,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,14 @@ export function toUserMessage(error, fallback = 'Действие не выпо
|
||||
return 'Пользователь не найден. Проверьте логин.';
|
||||
}
|
||||
|
||||
if (code === 'DEVICE_KEY_NOT_ACTUAL') {
|
||||
return 'Неверный пароль.';
|
||||
}
|
||||
|
||||
if (code === 'USER_NOT_LOCAL') {
|
||||
return 'Этот пользователь относится к другому серверу доступа.';
|
||||
}
|
||||
|
||||
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
|
||||
return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
|
||||
}
|
||||
|
||||
@@ -9938,3 +9938,73 @@ body.chat-topbar-overlay .composer-slot {
|
||||
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