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`:** diff --git a/docs/mcp-user-guide.md b/docs/mcp-user-guide.md new file mode 100644 index 0000000..9e629fc --- /dev/null +++ b/docs/mcp-user-guide.md @@ -0,0 +1,387 @@ +# 🤖 MCP Integration User Guide + +> Connect AI assistants directly to your C³ CELERITY panel for automated management. + +--- + +## 📖 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 | + +--- + +## 📋 Requirements + +| Requirement | Description | +|-------------|-------------| +| 🔑 **API Key** | With `mcp:enabled` scope | +| 🖥 **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!** + +> ⚠️ **Important**: Store your API key securely. You won't be able to see it again. + +--- + +## 🔌 Connecting AI Clients + +### 🖥 Claude Desktop + +Add to your Claude Desktop configuration file: + +| Platform | Config Path | +|----------|-------------| +| 🍎 **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: + +| 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 + +```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:** + +| 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 +{ + "name": "query", + "arguments": { + "resource": "users", + "filter": { "enabled": true }, + "limit": 50 + } +} +``` + +
+ +--- + +### 👤 manage_user — User Management + +> `users:write` scope required + +**Available Actions:** `create` | `update` | `delete` | `enable` | `disable` | `reset_traffic` + +
+📖 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 + +> `nodes:write` scope required + +**Available Actions:** `create` | `update` | `delete` | `sync` | `setup` | `reset_status` | `update_config` + +
+📖 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 + +> `nodes:write` scope required + +**Available Actions:** `create` | `update` | `delete` + +--- + +### 🔗 manage_cascade — Cascade Tunnels + +> `nodes:write` scope required + +**Available Actions:** `create` | `update` | `delete` | `deploy` | `undeploy` | `reconnect` + +--- + +### 💻 execute_ssh — Execute Commands + +> `nodes:write` scope required + +Executes a shell command on the server and returns the output. + +
+📖 Example: Check service status + +```json +{ + "name": "execute_ssh", + "arguments": { + "nodeId": "nodeId123", + "command": "systemctl status hysteria-server" + } +} +``` + +
+ +--- + +### 🖥 ssh_session — Interactive SSH Session + +> `nodes:write` scope required + +**Available Actions:** `start` | `input` | `close` + +--- + +### ⚙️ system_action — System Operations + +> `sync:write` scope required + +**Available Actions:** `sync_all` | `clear_cache` | `backup` | `kick_user` + +--- + +### 🗺 get_topology — Network Topology + +> `nodes:read` scope required + +Returns all active nodes and connections between them. + +--- + +### ❤️ health_check — Health Check + +> ✅ No scope required + +Returns uptime, sync status, cache stats, memory usage. + +--- + +## 📝 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 execute: + +| Step | Tool | Purpose | +|------|------|---------| +| 1 | `health_check` | Overall status | +| 2 | `query` with `resource=nodes` | List of nodes | +| 3 | — | Generate report with problematic nodes highlighted | + +--- + +### 👤 "Create user testuser with 50 GB limit" + +AI will execute: + +``` +manage_user → action=create, userId=testuser, trafficLimit=53687091200 +``` + +--- + +### 🔧 "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 | + +--- + +### 🖥 "Set up new server 192.168.1.100" + +AI will use the `setup_new_node` prompt: + +| 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 | + +--- + +## 🔑 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 new file mode 100644 index 0000000..f75c008 --- /dev/null +++ b/docs/mcp-user-guide.ru.md @@ -0,0 +1,387 @@ +# 🤖 Руководство по использованию MCP-интеграции + +> Подключите AI-ассистентов напрямую к панели C³ CELERITY для автоматизированного управления. + +--- + +## 📖 Что такое MCP? + +**Model Context Protocol (MCP)** — это протокол, который позволяет AI-ассистентам (Claude, Cursor и др.) напрямую взаимодействовать с панелью C³ CELERITY. + +### ✨ Возможности + +Через MCP AI может: + +| Возможность | Описание | +|-------------|----------| +| 👥 **Управление пользователями** | Создание, редактирование, блокировка VPN-пользователей | +| 🖥 **Настройка серверов** | Конфигурация серверов и нод | +| 💻 **SSH-команды** | Выполнение команд на серверах удалённо | +| 📊 **Мониторинг** | Получение статистики и логов | +| 🔧 **Диагностика** | Диагностика и устранение проблем | + +--- + +## 📋 Требования + +| Требование | Описание | +|------------|----------| +| 🔑 **API-ключ** | С правом `mcp:enabled` | +| 🖥 **AI-клиент** | 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-ключ в безопасном месте. Вы не сможете увидеть его снова. + +--- + +## 🔌 Подключение 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 — Управление пользователями + +> Требуется scope: `users:write` + +**Доступные действия:** `create` | `update` | `delete` | `enable` | `disable` | `reset_traffic` + +
+📖 Пример: Создать пользователя + +```json +{ + "name": "manage_user", + "arguments": { + "action": "create", + "userId": "user123", + "data": { + "username": "Иван Иванов", + "trafficLimit": 107374182400, + "maxDevices": 3, + "groups": ["groupId1"] + } + } +} +``` + +
+ +--- + +### 🖥 manage_node — Управление серверами + +> Требуется scope: `nodes:write` + +**Доступные действия:** `create` | `update` | `delete` | `sync` | `setup` | `reset_status` | `update_config` + +
+📖 Пример: Настроить ноду через SSH + +```json +{ + "name": "manage_node", + "arguments": { + "action": "setup", + "id": "nodeId123", + "setupOptions": { + "installHysteria": true, + "setupPortHopping": true, + "restartService": true + } + } +} +``` + +
+ +--- + +### 📁 manage_group — Управление группами + +> Требуется scope: `nodes:write` + +**Доступные действия:** `create` | `update` | `delete` + +--- + +### 🔗 manage_cascade — Каскадные туннели + +> Требуется scope: `nodes:write` + +**Доступные действия:** `create` | `update` | `delete` | `deploy` | `undeploy` | `reconnect` + +--- + +### 💻 execute_ssh — Выполнение команд + +> Требуется scope: `nodes:write` + +Выполняет shell-команду на сервере и возвращает вывод. + +
+📖 Пример: Проверить статус сервиса + +```json +{ + "name": "execute_ssh", + "arguments": { + "nodeId": "nodeId123", + "command": "systemctl status hysteria-server" + } +} +``` + +
+ +--- + +### 🖥 ssh_session — Интерактивная SSH-сессия + +> Требуется scope: `nodes:write` + +**Доступные действия:** `start` | `input` | `close` + +--- + +### ⚙️ system_action — Системные операции + +> Требуется scope: `sync:write` + +**Доступные действия:** `sync_all` | `clear_cache` | `backup` | `kick_user` + +--- + +### 🗺 get_topology — Топология сети + +> Требуется scope: `nodes:read` + +Возвращает все активные ноды и связи между ними. + +--- + +### ❤️ health_check — Проверка состояния + +> ✅ Scope не требуется + +Возвращает uptime, статус синхронизации, статистику кэша, использование памяти. + +--- + +## 📝 Готовые промпты + +> Промпты — это предустановленные сценарии, которые появляются как 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 выполнит: + +``` +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 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..a69656b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -67,7 +67,20 @@ "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", + "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", + "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 +476,18 @@ "security": "Security", "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)", @@ -563,6 +588,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 +898,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..d228a99 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -67,7 +67,20 @@ "enterLogin": "Введите логин", "enterPassword": "Введите пароль", "invalidCredentials": "Неверный логин или пароль", - "sessionExpired": "Сессия истекла, войдите снова" + "sessionExpired": "Сессия истекла, войдите снова", + "totpTitle": "Подтверждение входа", + "totpSetupTitle": "Подключение двухфакторной защиты", + "totpSettingsTitle": "Подтверждение настройки TOTP", + "totpSetupDescription": "Отсканируйте QR-код в приложении-аутентификаторе и введите 6-значный код.", + "totpLoginDescription": "Введите код из приложения-аутентификатора, чтобы завершить вход.", + "totpCode": "Код подтверждения", + "totpCodePlaceholder": "123456", + "totpVerifyButton": "Подтвердить", + "totpQrHint": "Если QR-код не читается, используйте секрет вручную.", + "totpManualSecret": "Секрет для ручного ввода", + "totpRequired": "Введите код подтверждения", + "invalidTotp": "Неверный код подтверждения", + "totpPendingExpired": "Сессия подтверждения истекла, войдите заново" }, "dashboard": { "title": "Главная", @@ -463,6 +476,18 @@ "security": "Безопасность", "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": "Отключен (доступ всем)", @@ -563,6 +588,9 @@ "minChars6": "Минимум 6 символов", "confirmPassword": "Подтверждение пароля", "repeatPassword": "Повторите пароль", + "enableTotpLabel": "Включить двухфакторную защиту (TOTP)", + "enableTotpHint": "После создания аккаунта потребуется подтверждение через приложение-аутентификатор.", + "noRecoveryWarning": "В версии 1 нет self-service восстановления. Сохраните секрет в безопасном месте.", "createAccount": "Создать аккаунт", "rememberWarning": "Запомните данные — восстановление невозможно!", "passwordsMismatch": "Пароли не совпадают!" @@ -870,4 +898,4 @@ "promptTroubleshoot": "Диагностика ноды через SSH", "promptExpiredUsers": "Найти и обработать истёкших пользователей" } -} +} \ No newline at end of file diff --git a/src/models/adminModel.js b/src/models/adminModel.js index 7872375..dc3bd8d 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,54 @@ 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, + }, + }); +}; +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 7daf03e..d5e8b80 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,360 @@ const render = (res, template, data = {}) => { // ==================== AUTH ==================== +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) { + delete req.session.setup2faPending; + } +} + +function clearLogin2faPending(req) { + if (req.session) { + delete req.session.login2faPending; + } +} + +function clearSettings2faPending(req) { + if (req.session) { + delete req.session.settings2faPending; + } +} + +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; +} + +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({ + secret, + username: pending.username, + }); + const qrDataUrl = await totpService.generateQrDataUrl(otpauthUrl); + + return res.render('totp-verify', { + mode: 'setup', + error, + username: pending.username, + secret, + qrDataUrl, + }); +} + +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', + error, + }); +} + +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, + 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 (req.path.includes('/settings/totp') && isSettings2faPendingValid(req)) { + return renderSettingsTotpPage(res, req.session.settings2faPending, 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 @@ -1499,36 +1777,226 @@ 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 }); + } +}); + +// 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' }); } }); @@ -2343,4 +2811,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..eeddc62 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') %> @@ -395,11 +399,11 @@
-
+

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

-
+
@@ -413,6 +417,12 @@
+ <% if (admin?.twoFactor?.enabled) { %> +
+ + +
+ <% } %>
@@ -420,6 +430,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-код.' %>

+ +
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ <% } %> +
+
+
@@ -1135,8 +1207,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(); @@ -1515,4 +1588,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..8203db1 --- /dev/null +++ b/views/totp-verify.ejs @@ -0,0 +1,88 @@ + + + + + + <%= typeof t !== 'undefined' ? t('auth.totpTitle') : 'Two-factor verification' %> | C3 CELERITY + + + + + + <% + 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; + %> + + + \ No newline at end of file