From 82d1546341bfbd4b01034df13e9e997f7da88c82 Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Thu, 19 Mar 2026 13:12:08 +0300 Subject: [PATCH 1/6] upd: docs --- docs/mcp-user-guide.md | 288 ++++++++++++++++++++ docs/mcp-user-guide.ru.md | 288 ++++++++++++++++++++ safe-update.md => docs/safe-update.md | 0 safe-update.ru.md => docs/safe-update.ru.md | 0 4 files changed, 576 insertions(+) create mode 100644 docs/mcp-user-guide.md create mode 100644 docs/mcp-user-guide.ru.md rename safe-update.md => docs/safe-update.md (100%) rename safe-update.ru.md => docs/safe-update.ru.md (100%) diff --git a/docs/mcp-user-guide.md b/docs/mcp-user-guide.md new file mode 100644 index 0000000..aa4f7c6 --- /dev/null +++ b/docs/mcp-user-guide.md @@ -0,0 +1,288 @@ +# MCP Integration User Guide + +## What is MCP? + +**Model Context Protocol (MCP)** is a protocol that allows AI assistants (Claude, Cursor, etc.) to directly interact with the C³ CELERITY panel. Through MCP, AI can: + +- Manage VPN users (create, edit, disable) +- Configure servers and nodes +- Execute SSH commands on servers +- Retrieve statistics and logs +- Diagnose issues + +## Requirements + +- API key with `mcp:enabled` scope +- MCP-compatible AI client (Claude Desktop, Cursor IDE, or any HTTP client with SSE support) + +## Creating an API Key + +1. Open panel → **Settings** → **API Keys** +2. Click **Create MCP API Key** +3. Enter a key name (e.g., "Claude Assistant") +4. Select permissions: + - Basic: `mcp:enabled` + read scopes (default) + - Extended: `users:write`, `nodes:write`, `sync:write` — for write operations +5. Copy the key — it's shown only once + +## Connecting AI Clients + +### Claude Desktop + +Add to your Claude Desktop configuration file: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "celerity": { + "url": "https://your-panel.com/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +``` + +### Cursor IDE + +Create a `.cursor/mcp.json` file in your project root: + +```json +{ + "mcpServers": { + "celerity": { + "url": "https://your-panel.com/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +``` + +### Custom Client + +Any HTTP client with SSE support can connect: + +- **Endpoint**: `https://your-panel.com/api/mcp` +- **Auth**: `Authorization: Bearer YOUR_API_KEY` +- **Content-Type**: `application/json` +- **Accept**: `text/event-stream` (for streaming) + +Example request: + +```bash +curl -X POST https://your-panel.com/api/mcp \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' +``` + +## Available Tools + +### query — Read Data + +Universal tool for retrieving data from the panel. + +| Resource | Description | Required scope | +|----------|-------------|----------------| +| `users` | List of users | `users:read` | +| `nodes` | List of servers | `nodes:read` | +| `groups` | Server groups | `stats:read` | +| `stats` | Traffic statistics | `stats:read` | +| `logs` | System logs | `stats:read` | + +Parameters: +- `resource` (required) — resource type +- `id` — specific item ID +- `filter` — filters (resource-dependent) +- `limit`, `page` — pagination +- `sortBy`, `sortOrder` — sorting + +**Example**: Get all active users + +```json +{ + "name": "query", + "arguments": { + "resource": "users", + "filter": { "enabled": true }, + "limit": 50 + } +} +``` + +### manage_user — User Management + +Actions: `create`, `update`, `delete`, `enable`, `disable`, `reset_traffic` + +Required scope: `users:write` + +**Example**: Create a user + +```json +{ + "name": "manage_user", + "arguments": { + "action": "create", + "userId": "user123", + "data": { + "username": "John Doe", + "trafficLimit": 107374182400, + "maxDevices": 3, + "groups": ["groupId1"] + } + } +} +``` + +### manage_node — Server Management + +Actions: `create`, `update`, `delete`, `sync`, `setup`, `reset_status`, `update_config` + +Required scope: `nodes:write` + +**Example**: Setup node via SSH + +```json +{ + "name": "manage_node", + "arguments": { + "action": "setup", + "id": "nodeId123", + "setupOptions": { + "installHysteria": true, + "setupPortHopping": true, + "restartService": true + } + } +} +``` + +### manage_group — Group Management + +Actions: `create`, `update`, `delete` + +Required scope: `nodes:write` + +### manage_cascade — Cascade Tunnels + +Actions: `create`, `update`, `delete`, `deploy`, `undeploy`, `reconnect` + +Required scope: `nodes:write` + +### execute_ssh — Execute Commands + +Executes a shell command on the server and returns the output. + +Required scope: `nodes:write` + +**Example**: Check service status + +```json +{ + "name": "execute_ssh", + "arguments": { + "nodeId": "nodeId123", + "command": "systemctl status hysteria-server" + } +} +``` + +### ssh_session — Interactive SSH Session + +Actions: `start`, `input`, `close` + +Required scope: `nodes:write` + +### system_action — System Operations + +Actions: `sync_all`, `clear_cache`, `backup`, `kick_user` + +Required scope: `sync:write` + +### get_topology — Network Topology + +Returns all active nodes and connections between them. + +Required scope: `nodes:read` + +### health_check — Health Check + +Returns uptime, sync status, cache stats, memory usage. + +No scope required. + +## Built-in Prompts + +Prompts are pre-configured scenarios that appear as slash commands in Claude Desktop (e.g., `/panel_overview`). + +| Prompt | Description | +|--------|-------------| +| `panel_overview` | System overview: nodes, users, health | +| `audit_nodes` | Find problematic nodes and suggest fixes | +| `user_report` | Detailed report for a specific user | +| `setup_new_node` | Step-by-step node addition guide | +| `troubleshoot_node` | Node diagnostics via SSH | +| `manage_expired_users` | Find and handle expired users | + +## Usage Examples + +### "Show me the status of all servers" + +AI will: +1. `health_check` — overall status +2. `query` with `resource=nodes` — list of nodes +3. Generate a report highlighting problematic nodes + +### "Create user testuser with 50 GB limit" + +AI will: +1. `manage_user` with `action=create`, `userId=testuser`, `trafficLimit=53687091200` + +### "Why is node DE-01 not working?" + +AI will: +1. `query` with `resource=nodes`, `id=` — get lastError +2. `execute_ssh` with command `systemctl status hysteria-server` +3. Analyze and suggest a solution + +### "Set up new server 192.168.1.100" + +AI will use the `setup_new_node` prompt and guide through all steps: +1. Collect data (IP, domain, SSH credentials) +2. Create node via `manage_node` +3. Run auto-setup via `manage_node action=setup` +4. Verify status + +## Access Permissions (Scopes) + +| Scope | Description | +|-------|-------------| +| `mcp:enabled` | Basic MCP access permission | +| `users:read` | Read users | +| `users:write` | Create, modify, delete users | +| `nodes:read` | Read servers and statistics | +| `nodes:write` | Manage servers, SSH commands | +| `stats:read` | Read statistics and logs | +| `sync:write` | Sync, backups, system operations | + +## Security + +- Store API keys in a secure location +- Use minimum required permissions +- Rotate keys periodically +- All MCP operations are logged in panel system logs + +--- + +**Sources**: +- `src/services/mcpService.js` — tool registry +- `src/routes/mcp.js` — MCP endpoints +- `src/mcp/prompts.js` — built-in prompts +- `src/locales/en.json` — MCP interface localization diff --git a/docs/mcp-user-guide.ru.md b/docs/mcp-user-guide.ru.md new file mode 100644 index 0000000..8124bab --- /dev/null +++ b/docs/mcp-user-guide.ru.md @@ -0,0 +1,288 @@ +# Руководство по использованию MCP-интеграции + +## Что такое MCP? + +**Model Context Protocol (MCP)** — это протокол, который позволяет AI-ассистентам (Claude, Cursor и др.) напрямую взаимодействовать с панелью C³ CELERITY. Через MCP AI может: + +- Управлять пользователями VPN (создание, редактирование, блокировка) +- Настраивать серверы и ноды +- Выполнять SSH-команды на серверах +- Получать статистику и логи +- Диагностировать проблемы + +## Требования + +- API-ключ с правом `mcp:enabled` +- AI-клиент с поддержкой MCP (Claude Desktop, Cursor IDE или другой HTTP-клиент с SSE) + +## Создание API-ключа + +1. Откройте панель → **Settings** → **API Keys** +2. Нажмите **Создать MCP API-ключ** +3. Укажите название ключа (например, "Claude Assistant") +4. Выберите права: + - Базовые: `mcp:enabled` + права на чтение (по умолчанию) + - Расширенные: `users:write`, `nodes:write`, `sync:write` — для операций записи +5. Скопируйте ключ — он показывается только один раз + +## Подключение AI-клиентов + +### Claude Desktop + +Добавьте в файл конфигурации Claude Desktop: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "celerity": { + "url": "https://your-panel.com/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +``` + +### Cursor IDE + +Создайте файл `.cursor/mcp.json` в корне проекта: + +```json +{ + "mcpServers": { + "celerity": { + "url": "https://your-panel.com/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +``` + +### Кастомный клиент + +Любой HTTP-клиент с поддержкой SSE может подключиться: + +- **Endpoint**: `https://your-panel.com/api/mcp` +- **Auth**: `Authorization: Bearer YOUR_API_KEY` +- **Content-Type**: `application/json` +- **Accept**: `text/event-stream` (для стриминга) + +Пример запроса: + +```bash +curl -X POST https://your-panel.com/api/mcp \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' +``` + +## Доступные инструменты + +### query — Чтение данных + +Универсальный инструмент для получения данных из панели. + +| Ресурс | Описание | Требуемый scope | +|--------|----------|-----------------| +| `users` | Список пользователей | `users:read` | +| `nodes` | Список серверов | `nodes:read` | +| `groups` | Группы серверов | `stats:read` | +| `stats` | Статистика трафика | `stats:read` | +| `logs` | Системные логи | `stats:read` | + +Параметры: +- `resource` (обязательно) — тип ресурса +- `id` — конкретный ID элемента +- `filter` — фильтры (зависят от ресурса) +- `limit`, `page` — пагинация +- `sortBy`, `sortOrder` — сортировка + +**Пример**: Получить всех активных пользователей + +```json +{ + "name": "query", + "arguments": { + "resource": "users", + "filter": { "enabled": true }, + "limit": 50 + } +} +``` + +### manage_user — Управление пользователями + +Действия: `create`, `update`, `delete`, `enable`, `disable`, `reset_traffic` + +Требуемый scope: `users:write` + +**Пример**: Создать пользователя + +```json +{ + "name": "manage_user", + "arguments": { + "action": "create", + "userId": "user123", + "data": { + "username": "Иван Иванов", + "trafficLimit": 107374182400, + "maxDevices": 3, + "groups": ["groupId1"] + } + } +} +``` + +### manage_node — Управление серверами + +Действия: `create`, `update`, `delete`, `sync`, `setup`, `reset_status`, `update_config` + +Требуемый scope: `nodes:write` + +**Пример**: Настроить ноду через SSH + +```json +{ + "name": "manage_node", + "arguments": { + "action": "setup", + "id": "nodeId123", + "setupOptions": { + "installHysteria": true, + "setupPortHopping": true, + "restartService": true + } + } +} +``` + +### manage_group — Управление группами + +Действия: `create`, `update`, `delete` + +Требуемый scope: `nodes:write` + +### manage_cascade — Каскадные туннели + +Действия: `create`, `update`, `delete`, `deploy`, `undeploy`, `reconnect` + +Требуемый scope: `nodes:write` + +### execute_ssh — Выполнение команд + +Выполняет shell-команду на сервере и возвращает вывод. + +Требуемый scope: `nodes:write` + +**Пример**: Проверить статус сервиса + +```json +{ + "name": "execute_ssh", + "arguments": { + "nodeId": "nodeId123", + "command": "systemctl status hysteria-server" + } +} +``` + +### ssh_session — Интерактивная SSH-сессия + +Действия: `start`, `input`, `close` + +Требуемый scope: `nodes:write` + +### system_action — Системные операции + +Действия: `sync_all`, `clear_cache`, `backup`, `kick_user` + +Требуемый scope: `sync:write` + +### get_topology — Топология сети + +Возвращает все активные ноды и связи между ними. + +Требуемый scope: `nodes:read` + +### health_check — Проверка состояния + +Возвращает uptime, статус синхронизации, статистику кэша, использование памяти. + +Scope не требуется. + +## Готовые промпты + +Промпты — это предустановленные сценарии, которые появляются как slash-команды в Claude Desktop (например, `/panel_overview`). + +| Промпт | Описание | +|--------|----------| +| `panel_overview` | Общий обзор системы: ноды, пользователи, здоровье | +| `audit_nodes` | Найти проблемные ноды и предложить исправления | +| `user_report` | Детальный отчёт по конкретному пользователю | +| `setup_new_node` | Пошаговое добавление новой ноды | +| `troubleshoot_node` | Диагностика ноды через SSH | +| `manage_expired_users` | Поиск и обработка истёкших пользователей | + +## Примеры использования + +### "Покажи состояние всех серверов" + +AI выполнит: +1. `health_check` — общее состояние +2. `query` с `resource=nodes` — список нод +3. Сформирует отчёт с проблемными нодами + +### "Создай пользователя testuser с лимитом 50 ГБ" + +AI выполнит: +1. `manage_user` с `action=create`, `userId=testuser`, `trafficLimit=53687091200` + +### "Почему нода DE-01 не работает?" + +AI выполнит: +1. `query` с `resource=nodes`, `id=` — получить lastError +2. `execute_ssh` с командой `systemctl status hysteria-server` +3. Проанализирует и предложит решение + +### "Настрой новый сервер 192.168.1.100" + +AI использует промпт `setup_new_node` и проведёт через все шаги: +1. Соберёт данные (IP, домен, SSH-реквизиты) +2. Создаст ноду через `manage_node` +3. Запустит автонастройку через `manage_node action=setup` +4. Проверит статус + +## Права доступа (Scopes) + +| Scope | Описание | +|-------|----------| +| `mcp:enabled` | Базовое право для MCP-доступа | +| `users:read` | Чтение пользователей | +| `users:write` | Создание, изменение, удаление пользователей | +| `nodes:read` | Чтение серверов и статистики | +| `nodes:write` | Управление серверами, SSH-команды | +| `stats:read` | Чтение статистики и логов | +| `sync:write` | Синхронизация, бэкапы, системные операции | + +## Безопасность + +- API-ключи хранитесь в безопасном месте +- Используйте минимально необходимые права +- Периодически ротируйте ключи +- Все MCP-операции логируются в системных логах панели + +--- + +**Источники**: +- `src/services/mcpService.js` — реестр инструментов +- `src/routes/mcp.js` — MCP-эндпоинты +- `src/mcp/prompts.js` — предустановленные промпты +- `src/locales/ru.json` — локализация интерфейса MCP diff --git a/safe-update.md b/docs/safe-update.md similarity index 100% rename from safe-update.md rename to docs/safe-update.md diff --git a/safe-update.ru.md b/docs/safe-update.ru.md similarity index 100% rename from safe-update.ru.md rename to docs/safe-update.ru.md From dbef04d6907395cc1d331b8b054ae111ca71ac1b Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Thu, 19 Mar 2026 13:17:35 +0300 Subject: [PATCH 2/6] fix: docs mcp --- README.md | 3 ++- README.ru.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 12dc43d..acffb7c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ ## ⚡ Quick Start -> Updating an existing installation? See [Safe Production Updates](safe-update.md). +> Updating an existing installation? See [Safe Production Updates](docs/safe-update.md). **1. Install Docker** (if not installed): ```bash @@ -57,6 +57,7 @@ docker compose up -d ``` **3. Open** `https://your-domain/panel` +> Planning to manage the panel from AI assistants? See [MCP Setup Guide](docs/mcp-user-guide.md). **Required `.env` variables:** ```env diff --git a/README.ru.md b/README.ru.md index 27ba3c4..cbc49e2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,7 +20,7 @@ ## ⚡ Быстрый старт -> Нужно обновить уже установленную панель? См. [Безопасное обновление на проде](safe-update.ru.md). +> Нужно обновить уже установленную панель? См. [Безопасное обновление на проде](docs/safe-update.ru.md). **1. Установите Docker** (если не установлен): @@ -57,6 +57,7 @@ docker compose up -d ``` **3. Откройте** `https://ваш-домен/panel` +> Планируете управлять панелью из AI-ассистента? См. [Гайд по настройке MCP](docs/mcp-user-guide.ru.md). **Обязательные переменные `.env`:** From 5240f773263f430d0b9a06267cf8a7e465496e3a Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Thu, 19 Mar 2026 13:32:21 +0300 Subject: [PATCH 3/6] upd: docs mcp --- docs/mcp-user-guide.md | 343 ++++++++++++++++++++++++-------------- docs/mcp-user-guide.ru.md | 335 ++++++++++++++++++++++++------------- 2 files changed, 438 insertions(+), 240 deletions(-) diff --git a/docs/mcp-user-guide.md b/docs/mcp-user-guide.md index aa4f7c6..9e629fc 100644 --- a/docs/mcp-user-guide.md +++ b/docs/mcp-user-guide.md @@ -1,38 +1,66 @@ -# MCP Integration User Guide +# 🤖 MCP Integration User Guide -## What is MCP? +> Connect AI assistants directly to your C³ CELERITY panel for automated management. -**Model Context Protocol (MCP)** is a protocol that allows AI assistants (Claude, Cursor, etc.) to directly interact with the C³ CELERITY panel. Through MCP, AI can: +--- + +## 📖 What is MCP? + +**Model Context Protocol (MCP)** is a protocol that allows AI assistants (Claude, Cursor, etc.) to directly interact with the C³ CELERITY panel. + +### ✨ Capabilities + +Through MCP, AI can: + +| Capability | Description | +|------------|-------------| +| 👥 **User Management** | Create, edit, disable VPN users | +| 🖥 **Server Configuration** | Configure servers and nodes | +| 💻 **SSH Commands** | Execute commands on servers remotely | +| 📊 **Monitoring** | Retrieve statistics and logs | +| 🔧 **Diagnostics** | Diagnose and troubleshoot issues | + +--- -- Manage VPN users (create, edit, disable) -- Configure servers and nodes -- Execute SSH commands on servers -- Retrieve statistics and logs -- Diagnose issues +## 📋 Requirements -## Requirements +| Requirement | Description | +|-------------|-------------| +| 🔑 **API Key** | With `mcp:enabled` scope | +| 🖥 **AI Client** | Claude Desktop, Cursor IDE, or any HTTP client with SSE support | -- API key with `mcp:enabled` scope -- MCP-compatible AI client (Claude Desktop, Cursor IDE, or any HTTP client with SSE support) +--- + +## 🔐 Creating an API Key + +### Step-by-Step + +1. 🖱 Open panel → **Settings** → **API Keys** +2. ➕ Click **Create MCP API Key** +3. ✏️ Enter a key name (e.g., `"Claude Assistant"`) +4. 🎛 Select permissions: + + | Type | Scopes | Use Case | + |------|--------|----------| + | 🟢 **Basic** | `mcp:enabled` + read scopes | Read-only access (default) | + | 🟡 **Extended** | `users:write`, `nodes:write`, `sync:write` | Write operations | + +5. 📋 Copy the key — **shown only once!** -## Creating an API Key +> ⚠️ **Important**: Store your API key securely. You won't be able to see it again. -1. Open panel → **Settings** → **API Keys** -2. Click **Create MCP API Key** -3. Enter a key name (e.g., "Claude Assistant") -4. Select permissions: - - Basic: `mcp:enabled` + read scopes (default) - - Extended: `users:write`, `nodes:write`, `sync:write` — for write operations -5. Copy the key — it's shown only once +--- -## Connecting AI Clients +## 🔌 Connecting AI Clients -### Claude Desktop +### 🖥 Claude Desktop Add to your Claude Desktop configuration file: -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +| Platform | Config Path | +|----------|-------------| +| 🍎 **macOS** | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| 🪟 **Windows** | `%APPDATA%\Claude\claude_desktop_config.json` | ```json { @@ -47,7 +75,7 @@ Add to your Claude Desktop configuration file: } ``` -### Cursor IDE +### 📝 Cursor IDE Create a `.cursor/mcp.json` file in your project root: @@ -64,16 +92,19 @@ Create a `.cursor/mcp.json` file in your project root: } ``` -### Custom Client +### 🔧 Custom Client Any HTTP client with SSE support can connect: -- **Endpoint**: `https://your-panel.com/api/mcp` -- **Auth**: `Authorization: Bearer YOUR_API_KEY` -- **Content-Type**: `application/json` -- **Accept**: `text/event-stream` (for streaming) +| Parameter | Value | +|-----------|-------| +| 📍 **Endpoint** | `https://your-panel.com/api/mcp` | +| 🔐 **Auth** | `Authorization: Bearer YOUR_API_KEY` | +| 📦 **Content-Type** | `application/json` | +| 📡 **Accept** | `text/event-stream` (for streaming) | -Example request: +
+📖 Example Request ```bash curl -X POST https://your-panel.com/api/mcp \ @@ -83,28 +114,36 @@ curl -X POST https://your-panel.com/api/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' ``` -## Available Tools +
+ +--- + +## 🛠 Available Tools -### query — Read Data +### 🔍 query — Read Data -Universal tool for retrieving data from the panel. +> Universal tool for retrieving data from the panel. -| Resource | Description | Required scope | +| Resource | Description | Required Scope | |----------|-------------|----------------| -| `users` | List of users | `users:read` | -| `nodes` | List of servers | `nodes:read` | -| `groups` | Server groups | `stats:read` | -| `stats` | Traffic statistics | `stats:read` | -| `logs` | System logs | `stats:read` | +| `users` | 👥 List of users | `users:read` | +| `nodes` | 🖥 List of servers | `nodes:read` | +| `groups` | 📁 Server groups | `stats:read` | +| `stats` | 📊 Traffic statistics | `stats:read` | +| `logs` | 📜 System logs | `stats:read` | -Parameters: -- `resource` (required) — resource type -- `id` — specific item ID -- `filter` — filters (resource-dependent) -- `limit`, `page` — pagination -- `sortBy`, `sortOrder` — sorting +**Parameters:** -**Example**: Get all active users +| Parameter | Required | Description | +|-----------|----------|-------------| +| `resource` | ✅ Yes | Resource type | +| `id` | ❌ No | Specific item ID | +| `filter` | ❌ No | Filters (resource-dependent) | +| `limit`, `page` | ❌ No | Pagination | +| `sortBy`, `sortOrder` | ❌ No | Sorting | + +
+📖 Example: Get all active users ```json { @@ -117,13 +156,18 @@ Parameters: } ``` -### manage_user — User Management +
+ +--- + +### 👤 manage_user — User Management -Actions: `create`, `update`, `delete`, `enable`, `disable`, `reset_traffic` +> `users:write` scope required -Required scope: `users:write` +**Available Actions:** `create` | `update` | `delete` | `enable` | `disable` | `reset_traffic` -**Example**: Create a user +
+📖 Example: Create a user ```json { @@ -141,13 +185,18 @@ Required scope: `users:write` } ``` -### manage_node — Server Management +
-Actions: `create`, `update`, `delete`, `sync`, `setup`, `reset_status`, `update_config` +--- + +### 🖥 manage_node — Server Management -Required scope: `nodes:write` +> `nodes:write` scope required -**Example**: Setup node via SSH +**Available Actions:** `create` | `update` | `delete` | `sync` | `setup` | `reset_status` | `update_config` + +
+📖 Example: Setup node via SSH ```json { @@ -164,25 +213,34 @@ Required scope: `nodes:write` } ``` -### manage_group — Group Management +
-Actions: `create`, `update`, `delete` +--- -Required scope: `nodes:write` +### 📁 manage_group — Group Management -### manage_cascade — Cascade Tunnels +> `nodes:write` scope required -Actions: `create`, `update`, `delete`, `deploy`, `undeploy`, `reconnect` +**Available Actions:** `create` | `update` | `delete` -Required scope: `nodes:write` +--- -### execute_ssh — Execute Commands +### 🔗 manage_cascade — Cascade Tunnels -Executes a shell command on the server and returns the output. +> `nodes:write` scope required + +**Available Actions:** `create` | `update` | `delete` | `deploy` | `undeploy` | `reconnect` + +--- + +### 💻 execute_ssh — Execute Commands + +> `nodes:write` scope required -Required scope: `nodes:write` +Executes a shell command on the server and returns the output. -**Example**: Check service status +
+📖 Example: Check service status ```json { @@ -194,95 +252,136 @@ Required scope: `nodes:write` } ``` -### ssh_session — Interactive SSH Session +
+ +--- + +### 🖥 ssh_session — Interactive SSH Session + +> `nodes:write` scope required + +**Available Actions:** `start` | `input` | `close` + +--- -Actions: `start`, `input`, `close` +### ⚙️ system_action — System Operations -Required scope: `nodes:write` +> `sync:write` scope required -### system_action — System Operations +**Available Actions:** `sync_all` | `clear_cache` | `backup` | `kick_user` -Actions: `sync_all`, `clear_cache`, `backup`, `kick_user` +--- -Required scope: `sync:write` +### 🗺 get_topology — Network Topology -### get_topology — Network Topology +> `nodes:read` scope required Returns all active nodes and connections between them. -Required scope: `nodes:read` +--- + +### ❤️ health_check — Health Check -### health_check — Health Check +> ✅ No scope required Returns uptime, sync status, cache stats, memory usage. -No scope required. +--- -## Built-in Prompts +## 📝 Built-in Prompts -Prompts are pre-configured scenarios that appear as slash commands in Claude Desktop (e.g., `/panel_overview`). +> Prompts are pre-configured scenarios that appear as slash commands in Claude Desktop (e.g., `/panel_overview`). | Prompt | Description | |--------|-------------| -| `panel_overview` | System overview: nodes, users, health | -| `audit_nodes` | Find problematic nodes and suggest fixes | -| `user_report` | Detailed report for a specific user | -| `setup_new_node` | Step-by-step node addition guide | -| `troubleshoot_node` | Node diagnostics via SSH | -| `manage_expired_users` | Find and handle expired users | +| 📊 `panel_overview` | System overview: nodes, users, health | +| 🔍 `audit_nodes` | Find problematic nodes and suggest fixes | +| 👤 `user_report` | Detailed report for a specific user | +| 🖥 `setup_new_node` | Step-by-step node addition guide | +| 🔧 `troubleshoot_node` | Node diagnostics via SSH | +| ⏰ `manage_expired_users` | Find and handle expired users | -## Usage Examples +--- -### "Show me the status of all servers" +## 💡 Usage Examples -AI will: -1. `health_check` — overall status -2. `query` with `resource=nodes` — list of nodes -3. Generate a report highlighting problematic nodes +### 📊 "Show me the status of all servers" -### "Create user testuser with 50 GB limit" +AI will execute: -AI will: -1. `manage_user` with `action=create`, `userId=testuser`, `trafficLimit=53687091200` +| Step | Tool | Purpose | +|------|------|---------| +| 1 | `health_check` | Overall status | +| 2 | `query` with `resource=nodes` | List of nodes | +| 3 | — | Generate report with problematic nodes highlighted | -### "Why is node DE-01 not working?" +--- -AI will: -1. `query` with `resource=nodes`, `id=` — get lastError -2. `execute_ssh` with command `systemctl status hysteria-server` -3. Analyze and suggest a solution +### 👤 "Create user testuser with 50 GB limit" -### "Set up new server 192.168.1.100" +AI will execute: -AI will use the `setup_new_node` prompt and guide through all steps: -1. Collect data (IP, domain, SSH credentials) -2. Create node via `manage_node` -3. Run auto-setup via `manage_node action=setup` -4. Verify status +``` +manage_user → action=create, userId=testuser, trafficLimit=53687091200 +``` -## Access Permissions (Scopes) +--- + +### 🔧 "Why is node DE-01 not working?" + +AI will execute: + +| Step | Tool | Purpose | +|------|------|---------| +| 1 | `query` with `resource=nodes`, `id=` | Get lastError | +| 2 | `execute_ssh` with `systemctl status hysteria-server` | Check service | +| 3 | — | Analyze and suggest solution | -| Scope | Description | -|-------|-------------| -| `mcp:enabled` | Basic MCP access permission | -| `users:read` | Read users | -| `users:write` | Create, modify, delete users | -| `nodes:read` | Read servers and statistics | -| `nodes:write` | Manage servers, SSH commands | -| `stats:read` | Read statistics and logs | -| `sync:write` | Sync, backups, system operations | +--- + +### 🖥 "Set up new server 192.168.1.100" -## Security +AI will use the `setup_new_node` prompt: -- Store API keys in a secure location -- Use minimum required permissions -- Rotate keys periodically -- All MCP operations are logged in panel system logs +| Step | Action | +|------|--------| +| 1 | 📋 Collect data (IP, domain, SSH credentials) | +| 2 | 🆕 Create node via `manage_node` | +| 3 | ⚙️ Run auto-setup via `manage_node action=setup` | +| 4 | ✅ Verify status | --- -**Sources**: -- `src/services/mcpService.js` — tool registry -- `src/routes/mcp.js` — MCP endpoints -- `src/mcp/prompts.js` — built-in prompts -- `src/locales/en.json` — MCP interface localization +## 🔑 Access Permissions (Scopes) + +| Scope | Description | Level | +|-------|-------------|-------| +| `mcp:enabled` | 🟢 Basic MCP access permission | Required | +| `users:read` | 👁 Read users | Read | +| `users:write` | ✏️ Create, modify, delete users | Write | +| `nodes:read` | 👁 Read servers and statistics | Read | +| `nodes:write` | ✏️ Manage servers, SSH commands | Write | +| `stats:read` | 👁 Read statistics and logs | Read | +| `sync:write` | ✏️ Sync, backups, system operations | Write | + +--- + +## 🛡 Security + +| Best Practice | Description | +|---------------|-------------| +| 🔒 **Secure Storage** | Store API keys in a secure location | +| 🎯 **Least Privilege** | Use minimum required permissions | +| 🔄 **Key Rotation** | Rotate keys periodically | +| 📝 **Audit Trail** | All MCP operations are logged in panel system logs | + +--- + +## 📚 Sources + +| File | Description | +|------|-------------| +| `src/services/mcpService.js` | Tool registry | +| `src/routes/mcp.js` | MCP endpoints | +| `src/mcp/prompts.js` | Built-in prompts | +| `src/locales/en.json` | MCP interface localization | diff --git a/docs/mcp-user-guide.ru.md b/docs/mcp-user-guide.ru.md index 8124bab..f75c008 100644 --- a/docs/mcp-user-guide.ru.md +++ b/docs/mcp-user-guide.ru.md @@ -1,38 +1,66 @@ -# Руководство по использованию MCP-интеграции +# 🤖 Руководство по использованию MCP-интеграции -## Что такое MCP? +> Подключите AI-ассистентов напрямую к панели C³ CELERITY для автоматизированного управления. -**Model Context Protocol (MCP)** — это протокол, который позволяет AI-ассистентам (Claude, Cursor и др.) напрямую взаимодействовать с панелью C³ CELERITY. Через MCP AI может: +--- + +## 📖 Что такое MCP? + +**Model Context Protocol (MCP)** — это протокол, который позволяет AI-ассистентам (Claude, Cursor и др.) напрямую взаимодействовать с панелью C³ CELERITY. + +### ✨ Возможности + +Через MCP AI может: + +| Возможность | Описание | +|-------------|----------| +| 👥 **Управление пользователями** | Создание, редактирование, блокировка VPN-пользователей | +| 🖥 **Настройка серверов** | Конфигурация серверов и нод | +| 💻 **SSH-команды** | Выполнение команд на серверах удалённо | +| 📊 **Мониторинг** | Получение статистики и логов | +| 🔧 **Диагностика** | Диагностика и устранение проблем | + +--- -- Управлять пользователями VPN (создание, редактирование, блокировка) -- Настраивать серверы и ноды -- Выполнять SSH-команды на серверах -- Получать статистику и логи -- Диагностировать проблемы +## 📋 Требования -## Требования +| Требование | Описание | +|------------|----------| +| 🔑 **API-ключ** | С правом `mcp:enabled` | +| 🖥 **AI-клиент** | Claude Desktop, Cursor IDE или другой HTTP-клиент с SSE | -- API-ключ с правом `mcp:enabled` -- AI-клиент с поддержкой MCP (Claude Desktop, Cursor IDE или другой HTTP-клиент с SSE) +--- + +## 🔐 Создание API-ключа + +### Пошаговая инструкция + +1. 🖱 Откройте панель → **Settings** → **API Keys** +2. ➕ Нажмите **Создать MCP API-ключ** +3. ✏️ Укажите название ключа (например, `"Claude Assistant"`) +4. 🎛 Выберите права: + + | Тип | Scopes | Применение | + |-----|--------|------------| + | 🟢 **Базовые** | `mcp:enabled` + права на чтение | Только чтение (по умолчанию) | + | 🟡 **Расширенные** | `users:write`, `nodes:write`, `sync:write` | Операции записи | + +5. 📋 Скопируйте ключ — **показывается только один раз!** -## Создание API-ключа +> ⚠️ **Важно**: Храните API-ключ в безопасном месте. Вы не сможете увидеть его снова. -1. Откройте панель → **Settings** → **API Keys** -2. Нажмите **Создать MCP API-ключ** -3. Укажите название ключа (например, "Claude Assistant") -4. Выберите права: - - Базовые: `mcp:enabled` + права на чтение (по умолчанию) - - Расширенные: `users:write`, `nodes:write`, `sync:write` — для операций записи -5. Скопируйте ключ — он показывается только один раз +--- -## Подключение AI-клиентов +## 🔌 Подключение AI-клиентов -### Claude Desktop +### 🖥 Claude Desktop Добавьте в файл конфигурации Claude Desktop: -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +| Платформа | Путь к конфигу | +|-----------|----------------| +| 🍎 **macOS** | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| 🪟 **Windows** | `%APPDATA%\Claude\claude_desktop_config.json` | ```json { @@ -47,7 +75,7 @@ } ``` -### Cursor IDE +### 📝 Cursor IDE Создайте файл `.cursor/mcp.json` в корне проекта: @@ -64,16 +92,19 @@ } ``` -### Кастомный клиент +### 🔧 Кастомный клиент Любой HTTP-клиент с поддержкой SSE может подключиться: -- **Endpoint**: `https://your-panel.com/api/mcp` -- **Auth**: `Authorization: Bearer YOUR_API_KEY` -- **Content-Type**: `application/json` -- **Accept**: `text/event-stream` (для стриминга) +| Параметр | Значение | +|----------|----------| +| 📍 **Endpoint** | `https://your-panel.com/api/mcp` | +| 🔐 **Auth** | `Authorization: Bearer YOUR_API_KEY` | +| 📦 **Content-Type** | `application/json` | +| 📡 **Accept** | `text/event-stream` (для стриминга) | -Пример запроса: +
+📖 Пример запроса ```bash curl -X POST https://your-panel.com/api/mcp \ @@ -83,28 +114,36 @@ curl -X POST https://your-panel.com/api/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' ``` -## Доступные инструменты +
+ +--- + +## 🛠 Доступные инструменты -### query — Чтение данных +### 🔍 query — Чтение данных -Универсальный инструмент для получения данных из панели. +> Универсальный инструмент для получения данных из панели. | Ресурс | Описание | Требуемый scope | |--------|----------|-----------------| -| `users` | Список пользователей | `users:read` | -| `nodes` | Список серверов | `nodes:read` | -| `groups` | Группы серверов | `stats:read` | -| `stats` | Статистика трафика | `stats:read` | -| `logs` | Системные логи | `stats:read` | +| `users` | 👥 Список пользователей | `users:read` | +| `nodes` | 🖥 Список серверов | `nodes:read` | +| `groups` | 📁 Группы серверов | `stats:read` | +| `stats` | 📊 Статистика трафика | `stats:read` | +| `logs` | 📜 Системные логи | `stats:read` | -Параметры: -- `resource` (обязательно) — тип ресурса -- `id` — конкретный ID элемента -- `filter` — фильтры (зависят от ресурса) -- `limit`, `page` — пагинация -- `sortBy`, `sortOrder` — сортировка +**Параметры:** -**Пример**: Получить всех активных пользователей +| Параметр | Обязательно | Описание | +|----------|-------------|----------| +| `resource` | ✅ Да | Тип ресурса | +| `id` | ❌ Нет | Конкретный ID элемента | +| `filter` | ❌ Нет | Фильтры (зависят от ресурса) | +| `limit`, `page` | ❌ Нет | Пагинация | +| `sortBy`, `sortOrder` | ❌ Нет | Сортировка | + +
+📖 Пример: Получить всех активных пользователей ```json { @@ -117,13 +156,18 @@ curl -X POST https://your-panel.com/api/mcp \ } ``` -### manage_user — Управление пользователями +
+ +--- + +### 👤 manage_user — Управление пользователями -Действия: `create`, `update`, `delete`, `enable`, `disable`, `reset_traffic` +> Требуется scope: `users:write` -Требуемый scope: `users:write` +**Доступные действия:** `create` | `update` | `delete` | `enable` | `disable` | `reset_traffic` -**Пример**: Создать пользователя +
+📖 Пример: Создать пользователя ```json { @@ -141,13 +185,18 @@ curl -X POST https://your-panel.com/api/mcp \ } ``` -### manage_node — Управление серверами +
-Действия: `create`, `update`, `delete`, `sync`, `setup`, `reset_status`, `update_config` +--- + +### 🖥 manage_node — Управление серверами -Требуемый scope: `nodes:write` +> Требуется scope: `nodes:write` -**Пример**: Настроить ноду через SSH +**Доступные действия:** `create` | `update` | `delete` | `sync` | `setup` | `reset_status` | `update_config` + +
+📖 Пример: Настроить ноду через SSH ```json { @@ -164,25 +213,34 @@ curl -X POST https://your-panel.com/api/mcp \ } ``` -### manage_group — Управление группами +
-Действия: `create`, `update`, `delete` +--- -Требуемый scope: `nodes:write` +### 📁 manage_group — Управление группами -### manage_cascade — Каскадные туннели +> Требуется scope: `nodes:write` -Действия: `create`, `update`, `delete`, `deploy`, `undeploy`, `reconnect` +**Доступные действия:** `create` | `update` | `delete` -Требуемый scope: `nodes:write` +--- -### execute_ssh — Выполнение команд +### 🔗 manage_cascade — Каскадные туннели -Выполняет shell-команду на сервере и возвращает вывод. +> Требуется scope: `nodes:write` + +**Доступные действия:** `create` | `update` | `delete` | `deploy` | `undeploy` | `reconnect` + +--- + +### 💻 execute_ssh — Выполнение команд + +> Требуется scope: `nodes:write` -Требуемый scope: `nodes:write` +Выполняет shell-команду на сервере и возвращает вывод. -**Пример**: Проверить статус сервиса +
+📖 Пример: Проверить статус сервиса ```json { @@ -194,95 +252,136 @@ curl -X POST https://your-panel.com/api/mcp \ } ``` -### ssh_session — Интерактивная SSH-сессия +
+ +--- + +### 🖥 ssh_session — Интерактивная SSH-сессия + +> Требуется scope: `nodes:write` + +**Доступные действия:** `start` | `input` | `close` + +--- -Действия: `start`, `input`, `close` +### ⚙️ system_action — Системные операции -Требуемый scope: `nodes:write` +> Требуется scope: `sync:write` -### system_action — Системные операции +**Доступные действия:** `sync_all` | `clear_cache` | `backup` | `kick_user` -Действия: `sync_all`, `clear_cache`, `backup`, `kick_user` +--- -Требуемый scope: `sync:write` +### 🗺 get_topology — Топология сети -### get_topology — Топология сети +> Требуется scope: `nodes:read` Возвращает все активные ноды и связи между ними. -Требуемый scope: `nodes:read` +--- + +### ❤️ health_check — Проверка состояния -### health_check — Проверка состояния +> ✅ Scope не требуется Возвращает uptime, статус синхронизации, статистику кэша, использование памяти. -Scope не требуется. +--- -## Готовые промпты +## 📝 Готовые промпты -Промпты — это предустановленные сценарии, которые появляются как slash-команды в Claude Desktop (например, `/panel_overview`). +> Промпты — это предустановленные сценарии, которые появляются как slash-команды в Claude Desktop (например, `/panel_overview`). | Промпт | Описание | |--------|----------| -| `panel_overview` | Общий обзор системы: ноды, пользователи, здоровье | -| `audit_nodes` | Найти проблемные ноды и предложить исправления | -| `user_report` | Детальный отчёт по конкретному пользователю | -| `setup_new_node` | Пошаговое добавление новой ноды | -| `troubleshoot_node` | Диагностика ноды через SSH | -| `manage_expired_users` | Поиск и обработка истёкших пользователей | +| 📊 `panel_overview` | Обзор системы: ноды, пользователи, здоровье | +| 🔍 `audit_nodes` | Найти проблемные ноды и предложить исправления | +| 👤 `user_report` | Детальный отчёт по конкретному пользователю | +| 🖥 `setup_new_node` | Пошаговое добавление новой ноды | +| 🔧 `troubleshoot_node` | Диагностика ноды через SSH | +| ⏰ `manage_expired_users` | Поиск и обработка истёкших пользователей | -## Примеры использования +--- + +## 💡 Примеры использования -### "Покажи состояние всех серверов" +### 📊 "Покажи состояние всех серверов" AI выполнит: -1. `health_check` — общее состояние -2. `query` с `resource=nodes` — список нод -3. Сформирует отчёт с проблемными нодами -### "Создай пользователя testuser с лимитом 50 ГБ" +| Шаг | Инструмент | Цель | +|-----|------------|------| +| 1 | `health_check` | Общее состояние | +| 2 | `query` с `resource=nodes` | Список нод | +| 3 | — | Сформирует отчёт с проблемными нодами | + +--- + +### 👤 "Создай пользователя testuser с лимитом 50 ГБ" AI выполнит: -1. `manage_user` с `action=create`, `userId=testuser`, `trafficLimit=53687091200` -### "Почему нода DE-01 не работает?" +``` +manage_user → action=create, userId=testuser, trafficLimit=53687091200 +``` + +--- + +### 🔧 "Почему нода DE-01 не работает?" AI выполнит: -1. `query` с `resource=nodes`, `id=` — получить lastError -2. `execute_ssh` с командой `systemctl status hysteria-server` -3. Проанализирует и предложит решение -### "Настрой новый сервер 192.168.1.100" +| Шаг | Инструмент | Цель | +|-----|------------|------| +| 1 | `query` с `resource=nodes`, `id=` | Получить lastError | +| 2 | `execute_ssh` с `systemctl status hysteria-server` | Проверить сервис | +| 3 | — | Проанализирует и предложит решение | -AI использует промпт `setup_new_node` и проведёт через все шаги: -1. Соберёт данные (IP, домен, SSH-реквизиты) -2. Создаст ноду через `manage_node` -3. Запустит автонастройку через `manage_node action=setup` -4. Проверит статус +--- -## Права доступа (Scopes) +### 🖥 "Настрой новый сервер 192.168.1.100" -| Scope | Описание | -|-------|----------| -| `mcp:enabled` | Базовое право для MCP-доступа | -| `users:read` | Чтение пользователей | -| `users:write` | Создание, изменение, удаление пользователей | -| `nodes:read` | Чтение серверов и статистики | -| `nodes:write` | Управление серверами, SSH-команды | -| `stats:read` | Чтение статистики и логов | -| `sync:write` | Синхронизация, бэкапы, системные операции | +AI использует промпт `setup_new_node`: -## Безопасность +| Шаг | Действие | +|-----|----------| +| 1 | 📋 Сбор данных (IP, домен, SSH-реквизиты) | +| 2 | 🆕 Создание ноды через `manage_node` | +| 3 | ⚙️ Автонастройка через `manage_node action=setup` | +| 4 | ✅ Проверка статуса | -- API-ключи хранитесь в безопасном месте -- Используйте минимально необходимые права -- Периодически ротируйте ключи -- Все MCP-операции логируются в системных логах панели +--- + +## 🔑 Права доступа (Scopes) + +| Scope | Описание | Уровень | +|-------|----------|---------| +| `mcp:enabled` | 🟢 Базовое право для MCP-доступа | Обязательно | +| `users:read` | 👁 Чтение пользователей | Чтение | +| `users:write` | ✏️ Создание, изменение, удаление | Запись | +| `nodes:read` | 👁 Чтение серверов и статистики | Чтение | +| `nodes:write` | ✏️ Управление серверами, SSH-команды | Запись | +| `stats:read` | 👁 Чтение статистики и логов | Чтение | +| `sync:write` | ✏️ Синхронизация, бэкапы, системные операции | Запись | --- -**Источники**: -- `src/services/mcpService.js` — реестр инструментов -- `src/routes/mcp.js` — MCP-эндпоинты -- `src/mcp/prompts.js` — предустановленные промпты -- `src/locales/ru.json` — локализация интерфейса MCP +## 🛡 Безопасность + +| Рекомендация | Описание | +|--------------|----------| +| 🔒 **Безопасное хранение** | Храните API-ключи в безопасном месте | +| 🎯 **Минимум прав** | Используйте минимально необходимые права | +| 🔄 **Ротация ключей** | Периодически меняйте ключи | +| 📝 **Аудит** | Все MCP-операции логируются в системных логах панели | + +--- + +## 📚 Источники + +| Файл | Описание | +|------|----------| +| `src/services/mcpService.js` | Реестр инструментов | +| `src/routes/mcp.js` | MCP-эндпоинты | +| `src/mcp/prompts.js` | Предустановленные промпты | +| `src/locales/ru.json` | Локализация интерфейса MCP | From 6d752152bf46d70c0410f73234ce4b6ecfea1eca Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Thu, 19 Mar 2026 23:59:38 +0300 Subject: [PATCH 4/6] feat: add optional admin TOTP authentication flow --- index.js | 97 ++++++++++-- package-lock.json | 92 +++++++++++ package.json | 1 + src/locales/en.json | 20 ++- src/locales/ru.json | 20 ++- src/models/adminModel.js | 60 +++++-- src/routes/panel.js | 303 +++++++++++++++++++++++++++++++----- src/services/totpService.js | 63 ++++++++ views/settings.ejs | 6 +- views/setup.ejs | 12 +- views/totp-verify.ejs | 69 ++++++++ 11 files changed, 669 insertions(+), 74 deletions(-) create mode 100644 src/services/totpService.js create mode 100644 views/totp-verify.ejs diff --git a/index.js b/index.js index f681850..3a57c3f 100644 --- a/index.js +++ b/index.js @@ -140,41 +140,116 @@ app.get('/health', async (req, res) => { app.use('/api/auth', authRoutes); const Admin = require('./src/models/adminModel'); +const totpService = require('./src/services/totpService'); const rateLimit = require('express-rate-limit'); +const API_LOGIN_2FA_PENDING_TTL_MS = 10 * 60 * 1000; + +function clearApiLogin2faPending(req) { + if (req.session) { + delete req.session.apiLogin2faPending; + } +} + +function isApiLogin2faPendingValid(req) { + const pending = req.session?.apiLogin2faPending; + if (!pending) return false; + if (!pending.createdAt) return false; + return (Date.now() - pending.createdAt) < API_LOGIN_2FA_PENDING_TTL_MS; +} + const apiLoginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: { error: 'Too many attempts. Try again in 15 minutes.' }, }); +const apiTotpVerifyLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 8, + message: { error: 'Too many verification attempts. Try again later.' }, +}); + app.post('/api/login', apiLoginLimiter, async (req, res) => { try { const { username, password } = req.body; - + if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } - + const admin = await Admin.verifyPassword(username, password); - if (!admin) { logger.warn(`[API] Failed login: ${username} (IP: ${req.ip})`); return res.status(401).json({ error: 'Invalid username or password' }); } - + + if (admin.twoFactor?.enabled) { + req.session.apiLogin2faPending = { + username: admin.username, + secretEncrypted: admin.twoFactor.secretEncrypted, + createdAt: Date.now(), + }; + delete req.session.authenticated; + delete req.session.adminUsername; + + logger.info(`[API] 2FA required for ${admin.username} (IP: ${req.ip})`); + return res.status(202).json({ + success: false, + requiresTwoFactor: true, + message: 'Two-factor verification required', + }); + } + + clearApiLogin2faPending(req); req.session.authenticated = true; req.session.adminUsername = admin.username; - + await Admin.recordSuccessfulLogin(admin.username); + logger.info(`[API] Login: ${admin.username} (IP: ${req.ip})`); - - res.json({ - success: true, + return res.json({ + success: true, username: admin.username, - message: 'Authentication successful. Use cookies for subsequent requests.' + message: 'Authentication successful. Use cookies for subsequent requests.', }); } catch (error) { - res.status(500).json({ error: error.message }); + return res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/login/totp', apiTotpVerifyLimiter, async (req, res) => { + try { + if (!isApiLogin2faPendingValid(req)) { + clearApiLogin2faPending(req); + return res.status(401).json({ error: 'Invalid username or password' }); + } + + const token = String(req.body?.token || '').trim(); + if (!token) { + return res.status(400).json({ error: 'Verification code required' }); + } + + const pending = req.session.apiLogin2faPending; + const secret = totpService.decryptSecret(pending.secretEncrypted); + const isValid = await totpService.verifyToken({ secret, token }); + if (!isValid) { + logger.warn(`[API] Failed login 2FA confirmation: ${pending.username} (IP: ${req.ip})`); + return res.status(401).json({ error: 'Invalid username or password' }); + } + + clearApiLogin2faPending(req); + req.session.authenticated = true; + req.session.adminUsername = pending.username; + await Admin.recordSuccessfulLogin(pending.username); + + logger.info(`[API] Login with 2FA: ${pending.username} (IP: ${req.ip})`); + return res.json({ + success: true, + username: pending.username, + message: 'Authentication successful. Use cookies for subsequent requests.', + }); + } catch (error) { + return res.status(500).json({ error: error.message }); } }); @@ -755,4 +830,4 @@ process.on('SIGINT', async () => { process.exit(0); }); -startServer(); +startServer(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7db493c..af5949d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "mongoose": "^8.0.0", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", + "otplib": "^13.4.0", "qrcode": "^1.5.4", "ssh2": "^1.15.0", "winston": "^3.11.0", @@ -937,6 +938,74 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", + "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz", + "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz", + "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz", + "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.4.0" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz", + "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz", + "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0" + } + }, "node_modules/@root/acme": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@root/acme/-/acme-3.1.0.tgz", @@ -1049,6 +1118,15 @@ "@root/encoding": "^1.0.1" } }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", @@ -3668,6 +3746,20 @@ "fn.name": "1.x.x" } }, + "node_modules/otplib": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz", + "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/plugin-base32-scure": "13.4.0", + "@otplib/plugin-crypto-noble": "13.4.0", + "@otplib/totp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", diff --git a/package.json b/package.json index 11d1110..2322ee0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "mongoose": "^8.0.0", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", + "otplib": "^13.4.0", "qrcode": "^1.5.4", "ssh2": "^1.15.0", "winston": "^3.11.0", diff --git a/src/locales/en.json b/src/locales/en.json index a81978a..f21e2e9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -67,7 +67,19 @@ "enterLogin": "Enter login", "enterPassword": "Enter password", "invalidCredentials": "Invalid username or password", - "sessionExpired": "Session expired, please login again" + "sessionExpired": "Session expired, please login again", + "totpTitle": "Two-factor verification", + "totpSetupTitle": "Set up two-factor authentication", + "totpSetupDescription": "Scan the QR code in your authenticator app and enter the 6-digit code.", + "totpLoginDescription": "Enter the code from your authenticator app to finish login.", + "totpCode": "Verification code", + "totpCodePlaceholder": "123456", + "totpVerifyButton": "Verify", + "totpQrHint": "If the QR code does not scan, use the manual secret.", + "totpManualSecret": "Manual secret", + "totpRequired": "Enter verification code", + "invalidTotp": "Invalid verification code", + "totpPendingExpired": "Verification session expired, please sign in again" }, "dashboard": { "title": "Dashboard", @@ -463,6 +475,7 @@ "security": "Security", "administrator": "Administrator", "lastLogin": "Last Login", + "twoFactorStatus": "Two-factor authentication", "never": "Never", "ipWhitelist": "IP Whitelist", "ipWhitelistDisabled": "Disabled (access for all)", @@ -563,6 +576,9 @@ "minChars6": "Minimum 6 characters", "confirmPassword": "Confirm Password", "repeatPassword": "Repeat password", + "enableTotpLabel": "Enable two-factor authentication (TOTP)", + "enableTotpHint": "After account creation you will need to confirm setup in an authenticator app.", + "noRecoveryWarning": "Version 1 has no self-service recovery. Save the secret in a safe place.", "createAccount": "Create Account", "rememberWarning": "Remember credentials — recovery is impossible!", "passwordsMismatch": "Passwords don't match!" @@ -870,4 +886,4 @@ "promptTroubleshoot": "Diagnose a node via SSH", "promptExpiredUsers": "Find and manage expired user accounts" } -} +} \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index 6d038ee..bcbd917 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -67,7 +67,19 @@ "enterLogin": "Введите логин", "enterPassword": "Введите пароль", "invalidCredentials": "Неверный логин или пароль", - "sessionExpired": "Сессия истекла, войдите снова" + "sessionExpired": "Сессия истекла, войдите снова", + "totpTitle": "Подтверждение входа", + "totpSetupTitle": "Подключение двухфакторной защиты", + "totpSetupDescription": "Отсканируйте QR-код в приложении-аутентификаторе и введите 6-значный код.", + "totpLoginDescription": "Введите код из приложения-аутентификатора, чтобы завершить вход.", + "totpCode": "Код подтверждения", + "totpCodePlaceholder": "123456", + "totpVerifyButton": "Подтвердить", + "totpQrHint": "Если QR-код не читается, используйте секрет вручную.", + "totpManualSecret": "Секрет для ручного ввода", + "totpRequired": "Введите код подтверждения", + "invalidTotp": "Неверный код подтверждения", + "totpPendingExpired": "Сессия подтверждения истекла, войдите заново" }, "dashboard": { "title": "Главная", @@ -463,6 +475,7 @@ "security": "Безопасность", "administrator": "Администратор", "lastLogin": "Последний вход", + "twoFactorStatus": "Двухфакторная защита", "never": "Никогда", "ipWhitelist": "IP Whitelist", "ipWhitelistDisabled": "Отключен (доступ всем)", @@ -563,6 +576,9 @@ "minChars6": "Минимум 6 символов", "confirmPassword": "Подтверждение пароля", "repeatPassword": "Повторите пароль", + "enableTotpLabel": "Включить двухфакторную защиту (TOTP)", + "enableTotpHint": "После создания аккаунта потребуется подтверждение через приложение-аутентификатор.", + "noRecoveryWarning": "В версии 1 нет self-service восстановления. Сохраните секрет в безопасном месте.", "createAccount": "Создать аккаунт", "rememberWarning": "Запомните данные — восстановление невозможно!", "passwordsMismatch": "Пароли не совпадают!" @@ -870,4 +886,4 @@ "promptTroubleshoot": "Диагностика ноды через SSH", "promptExpiredUsers": "Найти и обработать истёкших пользователей" } -} +} \ No newline at end of file diff --git a/src/models/adminModel.js b/src/models/adminModel.js index 7872375..23e882e 100644 --- a/src/models/adminModel.js +++ b/src/models/adminModel.js @@ -17,6 +17,20 @@ const adminSchema = new mongoose.Schema({ type: String, required: true, }, + twoFactor: { + enabled: { + type: Boolean, + default: false, + }, + secretEncrypted: { + type: String, + default: null, + }, + enabledAt: { + type: Date, + default: null, + }, + }, createdAt: { type: Date, default: Date.now, @@ -27,11 +41,19 @@ const adminSchema = new mongoose.Schema({ }, }); -adminSchema.statics.createAdmin = async function(username, password) { + +adminSchema.statics.createAdmin = async function(username, password, options = {}) { const hash = await bcrypt.hash(password, 12); + const twoFactor = options.twoFactor || {}; + return this.create({ username: username.toLowerCase().trim(), passwordHash: hash, + twoFactor: { + enabled: Boolean(twoFactor.enabled), + secretEncrypted: twoFactor.secretEncrypted || null, + enabledAt: twoFactor.enabledAt || null, + }, }); }; @@ -42,9 +64,6 @@ adminSchema.statics.verifyPassword = async function(username, password) { const isValid = await bcrypt.compare(password, admin.passwordHash); if (!isValid) return null; - admin.lastLogin = new Date(); - await admin.save(); - return admin; }; @@ -62,19 +81,26 @@ adminSchema.statics.changePassword = async function(username, newPassword) { ); }; -module.exports = mongoose.model('Admin', adminSchema); - - - - - - - - - - - - +adminSchema.statics.recordSuccessfulLogin = async function(username) { + return this.findOneAndUpdate( + { username: username.toLowerCase().trim() }, + { lastLogin: new Date() }, + { new: true } + ); +}; +adminSchema.statics.createAdminWithHash = async function(username, passwordHash, options = {}) { + const twoFactor = options.twoFactor || {}; + return this.create({ + username: username.toLowerCase().trim(), + passwordHash, + twoFactor: { + enabled: Boolean(twoFactor.enabled), + secretEncrypted: twoFactor.secretEncrypted || null, + enabledAt: twoFactor.enabledAt || null, + }, + }); +}; +module.exports = mongoose.model('Admin', adminSchema); \ No newline at end of file diff --git a/src/routes/panel.js b/src/routes/panel.js index 7daf03e..2f9d46c 100644 --- a/src/routes/panel.js +++ b/src/routes/panel.js @@ -6,8 +6,8 @@ const express = require('express'); const rateLimit = require('express-rate-limit'); const multer = require('multer'); +const bcrypt = require('bcryptjs'); const router = express.Router(); - // Multer для загрузки backup файлов const backupUpload = multer({ dest: '/tmp/backup-uploads/', @@ -29,6 +29,7 @@ const ApiKey = require('../models/apiKeyModel'); const webhookService = require('../services/webhookService'); const syncService = require('../services/syncService'); const cryptoService = require('../services/cryptoService'); +const totpService = require('../services/totpService'); const cache = require('../services/cacheService'); const nodeSetup = require('../services/nodeSetup'); const NodeSSH = require('../services/nodeSSH'); @@ -293,83 +294,305 @@ const render = (res, template, data = {}) => { // ==================== AUTH ==================== +const SETUP_2FA_PENDING_TTL_MS = 10 * 60 * 1000; +const LOGIN_2FA_PENDING_TTL_MS = 10 * 60 * 1000; + +function clearSetup2faPending(req) { + if (req.session) { + delete req.session.setup2faPending; + } +} + +function clearLogin2faPending(req) { + if (req.session) { + delete req.session.login2faPending; + } +} + +function isSetup2faPendingValid(req) { + const pending = req.session?.setup2faPending; + if (!pending) return false; + if (!pending.createdAt) return false; + return (Date.now() - pending.createdAt) < SETUP_2FA_PENDING_TTL_MS; +} + +function isLogin2faPendingValid(req) { + const pending = req.session?.login2faPending; + if (!pending) return false; + if (!pending.createdAt) return false; + return (Date.now() - pending.createdAt) < LOGIN_2FA_PENDING_TTL_MS; +} + +async function renderSetupTotpPage(res, pending, error = null) { + const secret = totpService.decryptSecret(pending.secretEncrypted); + const otpauthUrl = totpService.buildOtpAuthUrl({ + secret, + username: pending.username, + }); + const qrDataUrl = await totpService.generateQrDataUrl(otpauthUrl); + + return res.render('totp-verify', { + mode: 'setup', + error, + username: pending.username, + secret, + qrDataUrl, + }); +} + +function renderLoginTotpPage(res, error = null) { + return res.render('totp-verify', { + mode: 'login', + error, + }); +} + +const totpVerifyLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 8, + standardHeaders: true, + legacyHeaders: false, + handler: async (req, res) => { + logger.warn(`[Panel] TOTP verify rate limit exceeded (IP: ${req.ip})`); + const message = 'Слишком много попыток подтверждения. Попробуйте позже.'; + + if (req.path.includes('/setup') && isSetup2faPendingValid(req)) { + return renderSetupTotpPage(res, req.session.setup2faPending, message); + } + + if (isLogin2faPendingValid(req)) { + return renderLoginTotpPage(res, message); + } + + return res.redirect('/panel/login'); + }, +}); + // GET /panel/login - Логин или первичная регистрация router.get('/login', async (req, res) => { if (req.session && req.session.authenticated) { return res.redirect('/panel'); } - - // Проверяем есть ли админ в БД + + if (isLogin2faPendingValid(req)) { + return res.redirect('/panel/login/totp'); + } + + clearLogin2faPending(req); + const hasAdmin = await Admin.hasAdmin(); - if (!hasAdmin) { - // Первый запуск - показываем форму регистрации - return res.render('setup', { error: null }); + if (isSetup2faPendingValid(req)) { + return res.redirect('/panel/setup/totp'); + } + + clearSetup2faPending(req); + return res.render('setup', { error: null, enableTotp: false }); } - + + clearSetup2faPending(req); res.render('login', { error: null }); }); // POST /panel/setup - Первичная регистрация админа router.post('/setup', async (req, res) => { try { - // Проверяем что админа ещё нет const hasAdmin = await Admin.hasAdmin(); if (hasAdmin) { + clearSetup2faPending(req); return res.redirect('/panel/login'); } - - const { username, password, passwordConfirm } = req.body; - - // Валидация + + const { username, password, passwordConfirm, enableTotp } = req.body; + if (!username || username.length < 3) { - return res.render('setup', { error: 'Логин должен быть минимум 3 символа' }); + return res.render('setup', { error: 'Логин должен быть минимум 3 символа', enableTotp: enableTotp === 'on' }); } if (!password || password.length < 6) { - return res.render('setup', { error: 'Пароль должен быть минимум 6 символов' }); + return res.render('setup', { error: 'Пароль должен быть минимум 6 символов', enableTotp: enableTotp === 'on' }); } if (password !== passwordConfirm) { - return res.render('setup', { error: 'Пароли не совпадают' }); + return res.render('setup', { error: 'Пароли не совпадают', enableTotp: enableTotp === 'on' }); } - - // Создаём админа - await Admin.createAdmin(username, password); - - logger.info(`[Panel] Administrator created: ${username}`); - - // Авторизуем сразу - req.session.authenticated = true; - req.session.adminUsername = username.toLowerCase(); - - res.redirect('/panel'); + + const normalizedUsername = username.toLowerCase().trim(); + const setupWithTotp = enableTotp === 'on'; + + if (!setupWithTotp) { + await Admin.createAdmin(normalizedUsername, password); + await Admin.recordSuccessfulLogin(normalizedUsername); + clearSetup2faPending(req); + clearLogin2faPending(req); + req.session.authenticated = true; + req.session.adminUsername = normalizedUsername; + + logger.info(`[Panel] Administrator created: ${normalizedUsername}`); + return res.redirect('/panel'); + } + + const passwordHash = await bcrypt.hash(password, 12); + const secret = totpService.generateSecret(); + const secretEncrypted = totpService.encryptSecret(secret); + + req.session.setup2faPending = { + username: normalizedUsername, + passwordHash, + secretEncrypted, + createdAt: Date.now(), + }; + + logger.info(`[Panel] 2FA setup required for new admin: ${normalizedUsername} (IP: ${req.ip})`); + return renderSetupTotpPage(res, req.session.setup2faPending); } catch (error) { logger.error('[Panel] Admin creation error:', error.message); - res.render('setup', { error: 'Ошибка: ' + error.message }); + res.render('setup', { error: 'Ошибка: ' + error.message, enableTotp: req.body.enableTotp === 'on' }); + } +}); + +router.get('/setup/totp', async (req, res) => { + const hasAdmin = await Admin.hasAdmin(); + if (hasAdmin) { + clearSetup2faPending(req); + return res.redirect('/panel/login'); + } + + if (!isSetup2faPendingValid(req)) { + clearSetup2faPending(req); + return res.redirect('/panel/login'); + } + + return renderSetupTotpPage(res, req.session.setup2faPending); +}); + +router.post('/setup/totp', totpVerifyLimiter, async (req, res) => { + try { + const hasAdmin = await Admin.hasAdmin(); + if (hasAdmin) { + clearSetup2faPending(req); + return res.redirect('/panel/login'); + } + + if (!isSetup2faPendingValid(req)) { + clearSetup2faPending(req); + return res.redirect('/panel/login'); + } + + const pending = req.session.setup2faPending; + const token = String(req.body.token || '').trim(); + if (!token) { + return renderSetupTotpPage(res, pending, 'Введите код подтверждения'); + } + + const secret = totpService.decryptSecret(pending.secretEncrypted); + const isValid = await totpService.verifyToken({ secret, token }); + if (!isValid) { + logger.warn(`[Panel] Failed setup 2FA confirmation for ${pending.username} (IP: ${req.ip})`); + return renderSetupTotpPage(res, pending, 'Неверный код подтверждения'); + } + + await Admin.createAdminWithHash(pending.username, pending.passwordHash, { + twoFactor: { + enabled: true, + secretEncrypted: pending.secretEncrypted, + enabledAt: new Date(), + }, + }); + await Admin.recordSuccessfulLogin(pending.username); + clearLogin2faPending(req); + req.session.authenticated = true; + req.session.adminUsername = pending.username; + clearSetup2faPending(req); + + logger.info(`[Panel] Administrator created with 2FA: ${req.session.adminUsername}`); + return res.redirect('/panel'); + } catch (error) { + logger.error('[Panel] Setup 2FA confirmation error:', error.message); + if (isSetup2faPendingValid(req)) { + return renderSetupTotpPage(res, req.session.setup2faPending, 'Ошибка: ' + error.message); + } + return res.redirect('/panel/login'); } }); // POST /panel/login (с rate limiting) router.post('/login', loginLimiter, async (req, res) => { const { username, password } = req.body; - - // Проверяем есть ли админ в БД + const hasAdmin = await Admin.hasAdmin(); if (!hasAdmin) { return res.redirect('/panel/login'); } - - // Проверяем логин/пароль + const admin = await Admin.verifyPassword(username, password); - - if (admin) { - req.session.authenticated = true; - req.session.adminUsername = admin.username; - logger.info(`[Panel] Successful login: ${admin.username} from IP: ${req.ip}`); + + if (!admin) { + logger.warn(`[Panel] Failed login attempt: ${username} from IP: ${req.ip}`); + return res.render('login', { error: 'Неверный логин или пароль' }); + } + + clearSetup2faPending(req); + + if (admin.twoFactor?.enabled) { + req.session.login2faPending = { + username: admin.username, + secretEncrypted: admin.twoFactor.secretEncrypted, + createdAt: Date.now(), + }; + delete req.session.authenticated; + delete req.session.adminUsername; + + logger.info(`[Panel] 2FA required for ${admin.username} (IP: ${req.ip})`); + return res.redirect('/panel/login/totp'); + } + + clearLogin2faPending(req); + req.session.authenticated = true; + req.session.adminUsername = admin.username; + await Admin.recordSuccessfulLogin(admin.username); + + logger.info(`[Panel] Successful login: ${admin.username} from IP: ${req.ip}`); + return res.redirect('/panel'); +}); + +router.get('/login/totp', async (req, res) => { + if (req.session && req.session.authenticated) { return res.redirect('/panel'); } - - logger.warn(`[Panel] Failed login attempt: ${username} from IP: ${req.ip}`); - res.render('login', { error: 'Неверный логин или пароль' }); + + if (!isLogin2faPendingValid(req)) { + clearLogin2faPending(req); + return res.redirect('/panel/login'); + } + + return renderLoginTotpPage(res); +}); + +router.post('/login/totp', totpVerifyLimiter, async (req, res) => { + if (!isLogin2faPendingValid(req)) { + clearLogin2faPending(req); + return res.redirect('/panel/login'); + } + + const pending = req.session.login2faPending; + const token = String(req.body.token || '').trim(); + if (!token) { + return renderLoginTotpPage(res, 'Введите код подтверждения'); + } + + const secret = totpService.decryptSecret(pending.secretEncrypted); + const isValid = await totpService.verifyToken({ secret, token }); + if (!isValid) { + logger.warn(`[Panel] Failed login 2FA confirmation for ${pending.username} (IP: ${req.ip})`); + return renderLoginTotpPage(res, 'Неверный код подтверждения'); + } + + clearLogin2faPending(req); + req.session.authenticated = true; + req.session.adminUsername = pending.username; + await Admin.recordSuccessfulLogin(pending.username); + + logger.info(`[Panel] Successful login with 2FA: ${pending.username} (IP: ${req.ip})`); + return res.redirect('/panel'); }); // GET /panel/logout @@ -2343,4 +2566,4 @@ router.post('/settings/test-webhook', requireAuth, async (req, res) => { } }); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/src/services/totpService.js b/src/services/totpService.js new file mode 100644 index 0000000..d9abed6 --- /dev/null +++ b/src/services/totpService.js @@ -0,0 +1,63 @@ +const QRCode = require('qrcode'); +const otplib = require('otplib'); +const cryptoService = require('./cryptoService'); + +const DEFAULT_ISSUER = 'C3 CELERITY'; + +class TotpService { + generateSecret() { + return otplib.generateSecret(); + } + + encryptSecret(secret) { + return cryptoService.encrypt(secret); + } + + decryptSecret(secretEncrypted) { + return cryptoService.decrypt(secretEncrypted); + } + + buildOtpAuthUrl({ secret, username, issuer = DEFAULT_ISSUER }) { + return otplib.generateURI({ secret, accountName: String(username), issuer }); + } + + async generateQrDataUrl(otpauthUrl) { + return QRCode.toDataURL(otpauthUrl, { + errorCorrectionLevel: 'M', + margin: 1, + width: 220, + }); + } + + async verifyToken({ secret, token }) { + const normalizedToken = String(token || '').replace(/\s+/g, ''); + if (!normalizedToken) return false; + + const verificationResult = await otplib.verify({ + token: normalizedToken, + secret, + }); + + if (typeof verificationResult === 'boolean') { + return verificationResult; + } + + return Boolean(verificationResult && verificationResult.valid); + } + + async generateEnrollmentData({ username, issuer = DEFAULT_ISSUER }) { + const secret = this.generateSecret(); + const secretEncrypted = this.encryptSecret(secret); + const otpauthUrl = this.buildOtpAuthUrl({ secret, username, issuer }); + const qrDataUrl = await this.generateQrDataUrl(otpauthUrl); + + return { + secret, + secretEncrypted, + otpauthUrl, + qrDataUrl, + }; + } +} + +module.exports = new TotpService(); \ No newline at end of file diff --git a/views/settings.ejs b/views/settings.ejs index e209277..8e89c0f 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -365,6 +365,10 @@ <%= admin?.lastLogin ? new Date(admin.lastLogin).toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU') : t('settings.never') %> +
+ + <%= admin?.twoFactor?.enabled ? t('common.enabled') : t('common.disabled') %> +
<%= config.PANEL_IP_WHITELIST || t('settings.ipWhitelistDisabled') %> @@ -1515,4 +1519,4 @@ async function submitMcpKey(e) { btn.textContent = '<%= t("mcp.createKeyBtn") || "Create Key" %>'; } } - + \ No newline at end of file diff --git a/views/setup.ejs b/views/setup.ejs index c2a6b84..113280b 100644 --- a/views/setup.ejs +++ b/views/setup.ejs @@ -45,6 +45,16 @@ placeholder="<%= typeof t !== 'undefined' ? t('setup.repeatPassword') : 'Repeat password' %>" minlength="6" required>
+ + + <%= typeof t !== 'undefined' ? t('setup.enableTotpHint') : 'After account creation you will need to confirm setup in an authenticator app.' %> + +
+ <%= typeof t !== 'undefined' ? t('setup.noRecoveryWarning') : 'Version 1 has no self-service recovery. Save the secret in a safe place.' %> +
@@ -67,4 +77,4 @@ }); - + \ No newline at end of file diff --git a/views/totp-verify.ejs b/views/totp-verify.ejs new file mode 100644 index 0000000..c511edd --- /dev/null +++ b/views/totp-verify.ejs @@ -0,0 +1,69 @@ + + + + + + <%= typeof t !== 'undefined' ? t('auth.totpTitle') : 'Two-factor verification' %> | C3 CELERITY + + + + + + + + \ No newline at end of file From c23329796cff4b3daa160875058c5bd2399e66f7 Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Fri, 20 Mar 2026 01:14:33 +0300 Subject: [PATCH 5/6] feat: add self-service TOTP management in settings --- src/locales/en.json | 12 +++ src/locales/ru.json | 12 +++ src/models/adminModel.js | 28 +++++ src/routes/panel.js | 225 +++++++++++++++++++++++++++++++++++++++ views/settings.ejs | 71 +++++++++++- views/totp-verify.ejs | 39 +++++-- 6 files changed, 373 insertions(+), 14 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index f21e2e9..a69656b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -70,6 +70,7 @@ "sessionExpired": "Session expired, please login again", "totpTitle": "Two-factor verification", "totpSetupTitle": "Set up two-factor authentication", + "totpSettingsTitle": "Confirm TOTP setup", "totpSetupDescription": "Scan the QR code in your authenticator app and enter the 6-digit code.", "totpLoginDescription": "Enter the code from your authenticator app to finish login.", "totpCode": "Verification code", @@ -476,6 +477,17 @@ "administrator": "Administrator", "lastLogin": "Last Login", "twoFactorStatus": "Two-factor authentication", + "totpManagementTitle": "TOTP management", + "totpStatusLabel": "TOTP status", + "totpEnabledAtLabel": "Enabled at", + "totpEnableHint": "Confirm your current password, then scan the QR code in your authenticator app.", + "totpProtectedHint": "Disabling or rotating requires your current password and current TOTP code.", + "totpEnableVerifyDescription": "Scan the QR code and enter the code from your authenticator app to finish enabling TOTP.", + "totpRotateVerifyDescription": "Scan the new QR code and enter the code from your new device to complete secret rotation.", + "totpCurrentCodeLabel": "Current TOTP code", + "totpEnableAction": "Enable TOTP", + "totpRotateAction": "Rotate secret", + "totpDisableAction": "Disable TOTP", "never": "Never", "ipWhitelist": "IP Whitelist", "ipWhitelistDisabled": "Disabled (access for all)", diff --git a/src/locales/ru.json b/src/locales/ru.json index bcbd917..d228a99 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -70,6 +70,7 @@ "sessionExpired": "Сессия истекла, войдите снова", "totpTitle": "Подтверждение входа", "totpSetupTitle": "Подключение двухфакторной защиты", + "totpSettingsTitle": "Подтверждение настройки TOTP", "totpSetupDescription": "Отсканируйте QR-код в приложении-аутентификаторе и введите 6-значный код.", "totpLoginDescription": "Введите код из приложения-аутентификатора, чтобы завершить вход.", "totpCode": "Код подтверждения", @@ -476,6 +477,17 @@ "administrator": "Администратор", "lastLogin": "Последний вход", "twoFactorStatus": "Двухфакторная защита", + "totpManagementTitle": "Управление TOTP", + "totpStatusLabel": "Статус TOTP", + "totpEnabledAtLabel": "Подключено", + "totpEnableHint": "Подтвердите текущий пароль, затем отсканируйте QR-код в приложении-аутентификаторе.", + "totpProtectedHint": "Для отключения или перевыпуска требуется текущий пароль и текущий TOTP-код.", + "totpEnableVerifyDescription": "Отсканируйте QR-код и введите код из приложения-аутентификатора, чтобы завершить подключение TOTP.", + "totpRotateVerifyDescription": "Отсканируйте новый QR-код и введите код с нового устройства, чтобы завершить перевыпуск секрета.", + "totpCurrentCodeLabel": "Текущий TOTP-код", + "totpEnableAction": "Подключить TOTP", + "totpRotateAction": "Перевыпустить секрет", + "totpDisableAction": "Отключить TOTP", "never": "Никогда", "ipWhitelist": "IP Whitelist", "ipWhitelistDisabled": "Отключен (доступ всем)", diff --git a/src/models/adminModel.js b/src/models/adminModel.js index 23e882e..dc3bd8d 100644 --- a/src/models/adminModel.js +++ b/src/models/adminModel.js @@ -103,4 +103,32 @@ adminSchema.statics.createAdminWithHash = async function(username, passwordHash, }); }; +adminSchema.statics.setTwoFactorEnabled = async function(username, secretEncrypted, enabledAt = new Date()) { + return this.findOneAndUpdate( + { username: username.toLowerCase().trim() }, + { + twoFactor: { + enabled: true, + secretEncrypted, + enabledAt, + }, + }, + { new: true } + ); +}; + +adminSchema.statics.clearTwoFactor = async function(username) { + return this.findOneAndUpdate( + { username: username.toLowerCase().trim() }, + { + twoFactor: { + enabled: false, + secretEncrypted: null, + enabledAt: null, + }, + }, + { new: true } + ); +}; + module.exports = mongoose.model('Admin', adminSchema); \ No newline at end of file diff --git a/src/routes/panel.js b/src/routes/panel.js index 2f9d46c..8d6e44a 100644 --- a/src/routes/panel.js +++ b/src/routes/panel.js @@ -296,6 +296,7 @@ const render = (res, template, data = {}) => { const SETUP_2FA_PENDING_TTL_MS = 10 * 60 * 1000; const LOGIN_2FA_PENDING_TTL_MS = 10 * 60 * 1000; +const SETTINGS_2FA_PENDING_TTL_MS = 10 * 60 * 1000; function clearSetup2faPending(req) { if (req.session) { @@ -309,6 +310,12 @@ function clearLogin2faPending(req) { } } +function clearSettings2faPending(req) { + if (req.session) { + delete req.session.settings2faPending; + } +} + function isSetup2faPendingValid(req) { const pending = req.session?.setup2faPending; if (!pending) return false; @@ -323,6 +330,14 @@ function isLogin2faPendingValid(req) { return (Date.now() - pending.createdAt) < LOGIN_2FA_PENDING_TTL_MS; } +function isSettings2faPendingValid(req) { + const pending = req.session?.settings2faPending; + if (!pending) return false; + if (!pending.createdAt) return false; + if (!pending.intent || !['enable', 'rotate'].includes(pending.intent)) return false; + return (Date.now() - pending.createdAt) < SETTINGS_2FA_PENDING_TTL_MS; +} + async function renderSetupTotpPage(res, pending, error = null) { const secret = totpService.decryptSecret(pending.secretEncrypted); const otpauthUrl = totpService.buildOtpAuthUrl({ @@ -340,6 +355,35 @@ async function renderSetupTotpPage(res, pending, error = null) { }); } +async function renderSettingsTotpPage(res, pending, error = null) { + const t = typeof res.locals.t === 'function' ? res.locals.t : null; + const isRotate = pending.intent === 'rotate'; + const secret = totpService.decryptSecret(pending.secretEncrypted); + const otpauthUrl = totpService.buildOtpAuthUrl({ + secret, + username: pending.username, + }); + const qrDataUrl = await totpService.generateQrDataUrl(otpauthUrl); + + const title = t ? t('auth.totpSettingsTitle') : 'Confirm TOTP setup'; + const description = isRotate + ? (t ? t('settings.totpRotateVerifyDescription') : 'Отсканируйте новый QR-код и введите код из нового устройства, чтобы завершить перевыпуск.') + : (t ? t('settings.totpEnableVerifyDescription') : 'Отсканируйте QR-код и введите код из приложения-аутентификатора, чтобы завершить подключение.'); + + return res.render('totp-verify', { + mode: 'settings', + error, + intent: pending.intent, + formAction: '/panel/settings/totp', + title, + description, + buttonText: t ? t('auth.totpVerifyButton') : 'Verify', + username: pending.username, + secret, + qrDataUrl, + }); +} + function renderLoginTotpPage(res, error = null) { return res.render('totp-verify', { mode: 'login', @@ -347,6 +391,13 @@ function renderLoginTotpPage(res, error = null) { }); } +function redirectSettingsSecurity(res, { message = null, error = null } = {}) { + const params = new URLSearchParams({ tab: 'security' }); + if (message) params.set('message', message); + if (error) params.set('error', error); + return res.redirect(`/panel/settings?${params.toString()}`); +} + const totpVerifyLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 8, @@ -360,6 +411,10 @@ const totpVerifyLimiter = rateLimit({ return renderSetupTotpPage(res, req.session.setup2faPending, message); } + if (req.path.includes('/settings/totp') && isSettings2faPendingValid(req)) { + return renderSettingsTotpPage(res, req.session.settings2faPending, message); + } + if (isLogin2faPendingValid(req)) { return renderLoginTotpPage(res, message); } @@ -1755,6 +1810,176 @@ router.post('/settings/password', requireAuth, async (req, res) => { } }); +// POST /panel/settings/totp/start - Start enable/rotate flow +router.post('/settings/totp/start', requireAuth, totpVerifyLimiter, async (req, res) => { + try { + const intent = String(req.body.intent || '').trim(); + const currentPassword = String(req.body.currentPassword || ''); + const currentTotpCode = String(req.body.currentTotpCode || '').trim(); + + if (!['enable', 'rotate'].includes(intent)) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Некорректное действие TOTP' }); + } + + if (!currentPassword) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Введите текущий пароль' }); + } + + const admin = await Admin.verifyPassword(req.session.adminUsername, currentPassword); + if (!admin) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Неверный текущий пароль' }); + } + + if (intent === 'enable') { + if (admin.twoFactor?.enabled) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'TOTP уже включен' }); + } + + const enrollment = await totpService.generateEnrollmentData({ username: admin.username }); + req.session.settings2faPending = { + username: admin.username, + intent: 'enable', + secretEncrypted: enrollment.secretEncrypted, + createdAt: Date.now(), + }; + + return res.redirect('/panel/settings/totp'); + } + + if (!admin.twoFactor?.enabled || !admin.twoFactor?.secretEncrypted) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Для перевыпуска TOTP должен быть включен' }); + } + + if (!currentTotpCode) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Введите текущий TOTP-код' }); + } + + const currentSecret = totpService.decryptSecret(admin.twoFactor.secretEncrypted); + const isCurrentCodeValid = await totpService.verifyToken({ secret: currentSecret, token: currentTotpCode }); + if (!isCurrentCodeValid) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Неверный текущий TOTP-код' }); + } + + const enrollment = await totpService.generateEnrollmentData({ username: admin.username }); + req.session.settings2faPending = { + username: admin.username, + intent: 'rotate', + secretEncrypted: enrollment.secretEncrypted, + createdAt: Date.now(), + }; + + return res.redirect('/panel/settings/totp'); + } catch (error) { + clearSettings2faPending(req); + logger.error('[Panel] Settings TOTP start error:', error.message); + return redirectSettingsSecurity(res, { error: 'Ошибка запуска TOTP-flow' }); + } +}); + +// GET /panel/settings/totp - Confirm new TOTP secret +router.get('/settings/totp', requireAuth, async (req, res) => { + if (!isSettings2faPendingValid(req)) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Сессия подтверждения TOTP истекла' }); + } + + const pending = req.session.settings2faPending; + if (pending.username !== req.session.adminUsername) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Некорректная сессия подтверждения TOTP' }); + } + + return renderSettingsTotpPage(res, pending); +}); + +// POST /panel/settings/totp - Finish enable/rotate flow +router.post('/settings/totp', requireAuth, totpVerifyLimiter, async (req, res) => { + try { + if (!isSettings2faPendingValid(req)) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Сессия подтверждения TOTP истекла' }); + } + + const pending = req.session.settings2faPending; + if (pending.username !== req.session.adminUsername) { + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Некорректная сессия подтверждения TOTP' }); + } + + const token = String(req.body.token || '').trim(); + if (!token) { + return renderSettingsTotpPage(res, pending, 'Введите код подтверждения'); + } + + const secret = totpService.decryptSecret(pending.secretEncrypted); + const isValid = await totpService.verifyToken({ secret, token }); + if (!isValid) { + logger.warn(`[Panel] Failed settings TOTP confirmation for ${pending.username} (IP: ${req.ip})`); + return renderSettingsTotpPage(res, pending, 'Неверный код подтверждения'); + } + + await Admin.setTwoFactorEnabled(req.session.adminUsername, pending.secretEncrypted, new Date()); + clearSettings2faPending(req); + + logger.info(`[Panel] Settings TOTP ${pending.intent} completed for ${req.session.adminUsername}`); + const successMessage = pending.intent === 'rotate' + ? 'Новый TOTP-секрет успешно подключен' + : 'TOTP успешно подключен'; + + return redirectSettingsSecurity(res, { message: successMessage }); + } catch (error) { + logger.error('[Panel] Settings TOTP confirmation error:', error.message); + if (isSettings2faPendingValid(req)) { + return renderSettingsTotpPage(res, req.session.settings2faPending, 'Ошибка: ' + error.message); + } + clearSettings2faPending(req); + return redirectSettingsSecurity(res, { error: 'Ошибка подтверждения TOTP' }); + } +}); + +// POST /panel/settings/totp/disable - Disable TOTP for current admin +router.post('/settings/totp/disable', requireAuth, totpVerifyLimiter, async (req, res) => { + try { + const currentPassword = String(req.body.currentPassword || ''); + const currentTotpCode = String(req.body.currentTotpCode || '').trim(); + + if (!currentPassword || !currentTotpCode) { + return redirectSettingsSecurity(res, { error: 'Введите текущий пароль и TOTP-код' }); + } + + const admin = await Admin.verifyPassword(req.session.adminUsername, currentPassword); + if (!admin) { + return redirectSettingsSecurity(res, { error: 'Неверный текущий пароль' }); + } + + if (!admin.twoFactor?.enabled || !admin.twoFactor?.secretEncrypted) { + return redirectSettingsSecurity(res, { error: 'TOTP уже отключен' }); + } + + const currentSecret = totpService.decryptSecret(admin.twoFactor.secretEncrypted); + const isCurrentCodeValid = await totpService.verifyToken({ secret: currentSecret, token: currentTotpCode }); + if (!isCurrentCodeValid) { + return redirectSettingsSecurity(res, { error: 'Неверный текущий TOTP-код' }); + } + + await Admin.clearTwoFactor(req.session.adminUsername); + clearSettings2faPending(req); + + logger.info(`[Panel] Settings TOTP disabled for ${req.session.adminUsername}`); + return redirectSettingsSecurity(res, { message: 'TOTP успешно отключен' }); + } catch (error) { + logger.error('[Panel] Settings TOTP disable error:', error.message); + return redirectSettingsSecurity(res, { error: 'Ошибка отключения TOTP' }); + } +}); + // POST /panel/settings/reset-traffic - Сброс счетчика трафика для всех пользователей router.post('/settings/reset-traffic', requireAuth, async (req, res) => { try { diff --git a/views/settings.ejs b/views/settings.ejs index 8e89c0f..019ce21 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -399,11 +399,11 @@
-
+

<%= t('settings.changePassword') %>

-
+
@@ -424,6 +424,68 @@
+
+
+

<%= t('settings.totpManagementTitle') || 'TOTP' %>

+
+
+
+
+ + <%= admin?.twoFactor?.enabled ? t('common.enabled') : t('common.disabled') %> +
+
+ + <%= admin?.twoFactor?.enabledAt ? new Date(admin.twoFactor.enabledAt).toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU') : t('settings.never') %> +
+
+ + <% if (!admin?.twoFactor?.enabled) { %> +

<%= t('settings.totpEnableHint') || 'Подтвердите текущий пароль, затем подключите новое устройство по QR-коду.' %>

+ + +
+ + +
+ + + <% } else { %> +

<%= t('settings.totpProtectedHint') || 'Для отключения или перевыпуска нужно подтвердить текущий пароль и текущий TOTP-код.' %>

+ +
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ <% } %> +
+
+
@@ -1139,8 +1201,9 @@ document.querySelectorAll('.settings-tab').forEach(tab => { }); }); -// Restore tab from localStorage -const savedTab = localStorage.getItem('settings-tab'); +// Restore tab from query string or localStorage +const queryTab = new URLSearchParams(window.location.search).get('tab'); +const savedTab = queryTab || localStorage.getItem('settings-tab'); if (savedTab) { const tab = document.querySelector(`.settings-tab[data-tab="${savedTab}"]`); if (tab) tab.click(); diff --git a/views/totp-verify.ejs b/views/totp-verify.ejs index c511edd..8203db1 100644 --- a/views/totp-verify.ejs +++ b/views/totp-verify.ejs @@ -9,17 +9,36 @@ + <% + const isSetupMode = mode === 'setup'; + const isSettingsMode = mode === 'settings'; + const isEnrollmentMode = isSetupMode || isSettingsMode; + const defaultTitle = isSetupMode + ? (typeof t !== 'undefined' ? t('auth.totpSetupTitle') : 'Set up two-factor authentication') + : isSettingsMode + ? (typeof t !== 'undefined' ? t('auth.totpSettingsTitle') : 'Confirm TOTP setup') + : (typeof t !== 'undefined' ? t('auth.totpTitle') : 'Two-factor verification'); + const defaultDescription = isEnrollmentMode + ? (typeof t !== 'undefined' ? t('auth.totpSetupDescription') : 'Scan the QR code and enter the 6-digit code from your authenticator app.') + : (typeof t !== 'undefined' ? t('auth.totpLoginDescription') : 'Enter the code from your authenticator app to finish login.'); + const customFormAction = typeof formAction !== 'undefined' ? formAction : null; + const customButtonText = typeof buttonText !== 'undefined' ? buttonText : null; + const customTitle = typeof title !== 'undefined' ? title : null; + const customDescription = typeof description !== 'undefined' ? description : null; + const resolvedFormAction = customFormAction || ( + isSetupMode ? '/panel/setup/totp' : isSettingsMode ? '/panel/settings/totp' : '/panel/login/totp' + ); + const resolvedButtonText = customButtonText || (typeof t !== 'undefined' ? t('auth.totpVerifyButton') : 'Verify'); + const resolvedTitle = customTitle || defaultTitle; + const resolvedDescription = customDescription || defaultDescription; + %> From 400ad32c7f8328d17212ee67d4b2dfd8bd761f64 Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Fri, 20 Mar 2026 01:45:12 +0300 Subject: [PATCH 6/6] fix: require TOTP confirmation for password change --- src/routes/panel.js | 48 ++++++++++++++++++++++++++++++++------------- views/settings.ejs | 6 ++++++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/routes/panel.js b/src/routes/panel.js index 8d6e44a..d5e8b80 100644 --- a/src/routes/panel.js +++ b/src/routes/panel.js @@ -1777,36 +1777,56 @@ router.post('/settings', requireAuth, async (req, res) => { // POST /panel/settings/password - Смена пароля router.post('/settings/password', requireAuth, async (req, res) => { try { - const { currentPassword, newPassword, confirmPassword } = req.body; - + const currentPassword = String(req.body.currentPassword || ''); + const newPassword = String(req.body.newPassword || ''); + const confirmPassword = String(req.body.confirmPassword || ''); + const currentTotpCode = String(req.body.currentTotpCode || '').trim(); + // Валидация if (!currentPassword || !newPassword || !confirmPassword) { - return res.redirect('/panel/settings?error=' + encodeURIComponent('Заполните все поля')); + return redirectSettingsSecurity(res, { error: 'Заполните все поля' }); } - + if (newPassword.length < 6) { - return res.redirect('/panel/settings?error=' + encodeURIComponent('Новый пароль должен быть минимум 6 символов')); + return redirectSettingsSecurity(res, { error: 'Новый пароль должен быть минимум 6 символов' }); } - + if (newPassword !== confirmPassword) { - return res.redirect('/panel/settings?error=' + encodeURIComponent('Пароли не совпадают')); + return redirectSettingsSecurity(res, { error: 'Пароли не совпадают' }); } - + // Проверяем текущий пароль const admin = await Admin.verifyPassword(req.session.adminUsername, currentPassword); if (!admin) { - return res.redirect('/panel/settings?error=' + encodeURIComponent('Неверный текущий пароль')); + return redirectSettingsSecurity(res, { error: 'Неверный текущий пароль' }); } - + + if (admin.twoFactor?.enabled) { + if (!admin.twoFactor.secretEncrypted) { + logger.warn(`[Panel] Missing TOTP secret for enabled 2FA on password change: ${req.session.adminUsername} (IP: ${req.ip})`); + return redirectSettingsSecurity(res, { error: 'Ошибка настройки TOTP. Обратитесь к администратору' }); + } + + if (!currentTotpCode) { + return redirectSettingsSecurity(res, { error: 'Введите текущий TOTP-код' }); + } + + const currentSecret = totpService.decryptSecret(admin.twoFactor.secretEncrypted); + const isCurrentCodeValid = await totpService.verifyToken({ secret: currentSecret, token: currentTotpCode }); + if (!isCurrentCodeValid) { + return redirectSettingsSecurity(res, { error: 'Неверный текущий TOTP-код' }); + } + } + // Меняем пароль await Admin.changePassword(req.session.adminUsername, newPassword); - + logger.info(`[Panel] Password changed for: ${req.session.adminUsername}`); - - res.redirect('/panel/settings?message=' + encodeURIComponent('Пароль успешно изменён')); + + return redirectSettingsSecurity(res, { message: 'Пароль успешно изменён' }); } catch (error) { logger.error('[Panel] Password change error:', error.message); - res.redirect('/panel/settings?error=' + encodeURIComponent('Ошибка: ' + error.message)); + return redirectSettingsSecurity(res, { error: 'Ошибка: ' + error.message }); } }); diff --git a/views/settings.ejs b/views/settings.ejs index 019ce21..eeddc62 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -417,6 +417,12 @@
+ <% if (admin?.twoFactor?.enabled) { %> +
+ + +
+ <% } %>