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:
2026-09-19 21:53:35 +03:00
parent c059cd9f51
commit bf0d130ee8
23 changed files with 1115 additions and 269 deletions
+15 -4
View File
@@ -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(() => {