feat(cli): включение 2FA инстанс-админа командой totp-setup
2FA обязательна для инстанс-администраторов, но до её включения вход закрыт — получался замкнутый круг. Добавлены команды обслуживания: - `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом, печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый); - `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства; - код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить; - установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию печатает подсказку, чтобы секреты не оседали в логах установки). Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA → totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники, аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах нет.
This commit is contained in:
+15
-4
@@ -1,3 +1,5 @@
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
/**
|
||||
@@ -96,11 +98,16 @@ export interface GatewayConnectionOptions {
|
||||
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
logger?: GatewayLogger;
|
||||
/** Базовая задержка переподключения; в тестах её уменьшают. */
|
||||
baseBackoffMs?: number;
|
||||
/** Сколько ждать HELLO после открытия сокета. */
|
||||
helloTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HEARTBEAT_MS = 45_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
const HELLO_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface GatewayPacket {
|
||||
op: number;
|
||||
@@ -326,6 +333,8 @@ export class GatewayConnection {
|
||||
private readonly handlers: GatewayHandlers;
|
||||
private readonly socketFactory: (url: string) => WebSocket;
|
||||
private readonly logger: GatewayLogger;
|
||||
private readonly baseBackoffMs: number;
|
||||
private readonly helloTimeoutMs: number;
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private status: GatewayStatus = 'idle';
|
||||
@@ -343,6 +352,8 @@ export class GatewayConnection {
|
||||
this.url = options.url;
|
||||
this.handlers = options.handlers ?? {};
|
||||
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
|
||||
this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS;
|
||||
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
|
||||
this.socketFactory =
|
||||
options.socketFactory ??
|
||||
((url) => {
|
||||
@@ -403,13 +414,13 @@ export class GatewayConnection {
|
||||
try {
|
||||
socket = this.socketFactory(this.url);
|
||||
} catch (error) {
|
||||
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
|
||||
this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.socket = socket;
|
||||
if (reason !== undefined) {
|
||||
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
|
||||
this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`);
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -417,7 +428,7 @@ export class GatewayConnection {
|
||||
this.helloTimeout = setTimeout(() => {
|
||||
this.logger.warn('gateway: HELLO timeout');
|
||||
this.dropSocket();
|
||||
}, 15_000);
|
||||
}, this.helloTimeoutMs);
|
||||
};
|
||||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
@@ -464,7 +475,7 @@ export class GatewayConnection {
|
||||
return;
|
||||
}
|
||||
this.attempt += 1;
|
||||
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
this.setStatus('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user