diff --git a/Dockerfile.moderation b/Dockerfile.moderation index 00a0826..d9eac02 100644 --- a/Dockerfile.moderation +++ b/Dockerfile.moderation @@ -10,7 +10,7 @@ RUN npm ci COPY moderation/frontend ./ # Сборка проекта -ARG VITE_API_URL +ARG VITE_API_URL=https://moderation.nkm.guru/api ENV VITE_API_URL=$VITE_API_URL RUN npm run build diff --git a/Dockerfile.moderation-backend b/Dockerfile.moderation-backend new file mode 100644 index 0000000..87c2d73 --- /dev/null +++ b/Dockerfile.moderation-backend @@ -0,0 +1,21 @@ +FROM node:20-alpine + +WORKDIR /app + +# Установка зависимостей из корня проекта +COPY package*.json ./ +RUN npm ci --only=production + +# Копирование всего проекта (нужно для доступа к backend/ и moderation/) +COPY . . + +# Создание директории для uploads (если нужно) +RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel + +EXPOSE 3001 + +ENV NODE_ENV=production +ENV MODERATION_PORT=3001 + +CMD ["node", "moderation/backend/server.js"] + diff --git a/ENV_EXAMPLE.txt b/ENV_EXAMPLE.txt index 78312c4..1143ee4 100644 --- a/ENV_EXAMPLE.txt +++ b/ENV_EXAMPLE.txt @@ -62,3 +62,34 @@ CACHE_TTL_POSTS=300 CACHE_TTL_USERS=600 CACHE_TTL_SEARCH=180 +# Модерация +MODERATION_PORT=3001 +MODERATION_CORS_ORIGIN=https://moderation.nkm.guru +VITE_MODERATION_API_URL=https://moderation.nkm.guru/api + +# Email для кодов подтверждения админа +OWNER_EMAIL=aaem9848@gmail.com + +# Email настройки для отправки писем (выберите один вариант) +# AWS SES +EMAIL_PROVIDER=aws +AWS_SES_ACCESS_KEY_ID=your_aws_access_key +AWS_SES_SECRET_ACCESS_KEY=your_aws_secret_key +AWS_SES_REGION=us-east-1 +EMAIL_FROM=noreply@nakama.guru + +# Или Yandex Cloud +# EMAIL_PROVIDER=yandex +# YANDEX_SMTP_USER=your_email@yandex.ru +# YANDEX_SMTP_PASSWORD=your_app_password +# EMAIL_FROM=noreply@nakama.guru + +# Или SMTP +# EMAIL_PROVIDER=smtp +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=your_email@example.com +# SMTP_PASSWORD=your_password +# SMTP_SECURE=false +# EMAIL_FROM=noreply@nakama.guru + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..58e3b18 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,144 @@ +# Безопасность модераторского сайта + +## Меры безопасности + +### 1. Шифрование паролей + +✅ **Пароли зашифрованы с использованием bcrypt** +- Используется `bcrypt.hash()` с солью 10 раундов +- Пароли никогда не хранятся в открытом виде +- `passwordHash` имеет `select: false` в модели User - не возвращается в API ответах + +### 2. Защита API + +✅ **Rate Limiting** +- Авторизация: 5 попыток за 15 секунд +- Отправка кода: 1 запрос в минуту +- Общий лимит: 100 запросов за 15 секунд + +✅ **Middleware безопасности** +- Helmet - защита HTTP headers +- XSS Protection - защита от XSS атак +- MongoDB Sanitize - защита от NoSQL injection +- HPP Protection - защита от HTTP Parameter Pollution +- DDoS Protection - защита от DDoS + +✅ **JWT Токены** +- Access token: 5 минут жизни +- Refresh token: 7 дней +- Токены хранятся в httpOnly cookies в production +- Секретные ключи настраиваются через .env + +### 3. Защита данных + +✅ **Email не возвращается в API ответах** +- Email используется только для авторизации +- Не включается в JWT payload +- Не возвращается в `/me` и других эндпоинтах + +✅ **Пароли** +- Никогда не возвращаются в ответах API +- Хранятся только как bcrypt hash +- Проверка через `bcrypt.compare()` только при входе + +### 4. Авторизация + +✅ **Двухфакторная авторизация для админов** +- Код подтверждения отправляется на email владельца (aaem9848@gmail.com) +- Код действителен 5 минут +- Fallback на Telegram если email не работает + +✅ **Проверка ролей** +- Все эндпоинты модерации проверяют роль пользователя +- Только `moderator` и `admin` имеют доступ +- Владелец имеет дополнительные права + +### 5. Валидация данных + +✅ **Валидация входных данных** +- Email валидируется через `validator.isEmail()` +- Пароль минимум 6 символов +- Все данные санитизируются перед сохранением + +### 6. Логирование безопасности + +✅ **События безопасности логируются** +- Неудачные попытки входа +- Невалидные токены +- Попытки несанкционированного доступа +- Все события логируются через `logSecurityEvent()` + +### 7. CORS и Headers + +✅ **CORS настройки** +- Настраивается через `MODERATION_CORS_ORIGIN` +- Credentials включены для cookies +- Ограниченные методы и headers + +✅ **Security Headers** +- X-Frame-Options: SAMEORIGIN +- X-Content-Type-Options: nosniff +- X-XSS-Protection: 1; mode=block +- Referrer-Policy: no-referrer-when-downgrade + +## Рекомендации для production + +1. **Измените секретные ключи**: + ```bash + JWT_SECRET=your_very_secure_random_string + JWT_ACCESS_SECRET=another_secure_random_string + JWT_REFRESH_SECRET=yet_another_secure_random_string + ``` + +2. **Настройте HTTPS** (обязательно): + - SSL сертификат через Let's Encrypt + - Все HTTP редиректятся на HTTPS + +3. **Защитите .env файл**: + ```bash + chmod 600 .env + ``` + - Не коммитьте .env в git + - Используйте переменные окружения в Docker + +4. **Ограничьте доступ к MongoDB**: + - Используйте firewall + - Ограничьте IP адреса + - Используйте аутентификацию MongoDB + +5. **Мониторинг**: + - Следите за логами безопасности + - Настройте алерты на подозрительную активность + - Регулярно проверяйте логи авторизации + +## Переменные окружения для безопасности + +```bash +# JWT секреты (ОБЯЗАТЕЛЬНО измените в production!) +JWT_SECRET=your_secure_secret +JWT_ACCESS_SECRET=your_access_secret +JWT_REFRESH_SECRET=your_refresh_secret + +# Email для кодов подтверждения админа +OWNER_EMAIL=aaem9848@gmail.com + +# CORS (ограничьте в production) +MODERATION_CORS_ORIGIN=https://moderation.nkm.guru + +# Внутренний токен бота (опционально) +INTERNAL_BOT_TOKEN=your_internal_bot_token +``` + +## Проверка безопасности + +```bash +# Проверить что пароли не возвращаются +curl -H "Authorization: Bearer YOUR_TOKEN" https://moderation.nkm.guru/api/moderation-auth/me + +# Проверить rate limiting +for i in {1..10}; do curl -X POST https://moderation.nkm.guru/api/moderation-auth/login -d '{"email":"test@test.com","password":"wrong"}'; done + +# Проверить защиту от SQL injection +curl -X POST https://moderation.nkm.guru/api/moderation-auth/login -d '{"email":{"$ne":null},"password":"test"}' +``` + diff --git a/backend/config/index.js b/backend/config/index.js index 05baeb1..0ff3687 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -87,6 +87,33 @@ module.exports = { publicBucket: process.env.MINIO_PUBLIC_BUCKET === 'true' }, + // Email конфигурация + ownerEmail: process.env.OWNER_EMAIL || 'aaem9848@gmail.com', + internalBotToken: process.env.INTERNAL_BOT_TOKEN || null, + email: { + provider: process.env.EMAIL_PROVIDER || 'aws', // aws, yandex, smtp + from: process.env.EMAIL_FROM || 'noreply@nakama.guru', + aws: { + accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY, + region: process.env.AWS_SES_REGION || 'us-east-1' + }, + yandex: { + host: process.env.YANDEX_SMTP_HOST || 'smtp.yandex.ru', + port: parseInt(process.env.YANDEX_SMTP_PORT || '465', 10), + secure: process.env.YANDEX_SMTP_SECURE !== 'false', + user: process.env.YANDEX_SMTP_USER, + password: process.env.YANDEX_SMTP_PASSWORD + }, + smtp: { + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: process.env.SMTP_SECURE === 'true', + user: process.env.SMTP_USER, + password: process.env.SMTP_PASSWORD + } + }, + // Проверки isDevelopment: () => process.env.NODE_ENV === 'development', isProduction: () => process.env.NODE_ENV === 'production', diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 716af84..48f462d 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -494,9 +494,71 @@ const authenticateModeration = async (req, res, next) => { } }; +// Middleware для проверки JWT токена (для модерации через логин/пароль) +const authenticateJWT = async (req, res, next) => { + try { + // Получить токен из заголовка или cookie + let token = null; + + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.slice(7); + } else if (req.cookies) { + const { verifyAccessToken, ACCESS_COOKIE } = require('../utils/tokens'); + token = req.cookies[ACCESS_COOKIE]; + } + + if (!token) { + return res.status(401).json({ error: 'Требуется авторизация' }); + } + + // Проверить токен + const { verifyAccessToken } = require('../utils/tokens'); + let payload; + try { + payload = verifyAccessToken(token); + } catch (error) { + logSecurityEvent('INVALID_JWT_TOKEN', req); + return res.status(401).json({ error: 'Неверный токен' }); + } + + // Найти пользователя + const user = await User.findById(payload.userId); + + if (!user) { + return res.status(401).json({ error: 'Пользователь не найден' }); + } + + if (user.banned) { + return res.status(403).json({ error: 'Аккаунт заблокирован' }); + } + + req.user = user; + next(); + } catch (error) { + console.error('Ошибка JWT авторизации:', error); + res.status(401).json({ error: 'Ошибка авторизации' }); + } +}; + +// Комбинированный middleware: Telegram или JWT +const authenticateModerationFlexible = async (req, res, next) => { + // Попробовать Telegram авторизацию + const authHeader = req.headers.authorization || ''; + const hasTelegramAuth = authHeader.startsWith('tma ') || req.headers['x-telegram-init-data']; + + if (hasTelegramAuth) { + return authenticateModeration(req, res, next); + } else { + return authenticateJWT(req, res, next); + } +}; + module.exports = { authenticate, authenticateModeration, + authenticateJWT, + authenticateModerationFlexible, requireModerator, requireAdmin, touchUserActivity, diff --git a/backend/middleware/botAuth.js b/backend/middleware/botAuth.js new file mode 100644 index 0000000..c5ca7b0 --- /dev/null +++ b/backend/middleware/botAuth.js @@ -0,0 +1,48 @@ +const config = require('../config'); +const { logSecurityEvent } = require('./logger'); + +// Middleware для проверки что запрос идет от внутреннего бота +// Используется для ботов, которые работают внутри Docker сети +const authenticateBot = (req, res, next) => { + // Получить IP адрес клиента + const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress; + const forwardedFor = req.headers['x-forwarded-for']; + const realIp = forwardedFor ? forwardedFor.split(',')[0].trim() : clientIp; + + // Проверить внутренний токен бота (если установлен) + const botToken = req.headers['x-bot-token']; + const expectedBotToken = process.env.INTERNAL_BOT_TOKEN || config.internalBotToken; + + // Проверить что запрос идет из внутренней сети Docker + const isInternalNetwork = + realIp === '127.0.0.1' || + realIp === '::1' || + realIp === '::ffff:127.0.0.1' || + realIp.startsWith('172.') || // Docker network range + realIp.startsWith('192.168.') || + realIp.startsWith('10.'); + + // Если установлен токен - проверить его + if (expectedBotToken) { + if (botToken === expectedBotToken) { + req.isBot = true; + return next(); + } + logSecurityEvent('INVALID_BOT_TOKEN', req, { ip: realIp }); + return res.status(403).json({ error: 'Неверный токен бота' }); + } + + // Если токена нет - разрешить только из внутренней сети + if (isInternalNetwork || !config.isProduction()) { + req.isBot = true; + return next(); + } + + logSecurityEvent('EXTERNAL_BOT_ACCESS_ATTEMPT', req, { ip: realIp }); + return res.status(403).json({ error: 'Доступ только из внутренней сети' }); +}; + +module.exports = { + authenticateBot +}; + diff --git a/backend/models/EmailVerificationCode.js b/backend/models/EmailVerificationCode.js new file mode 100644 index 0000000..a72d113 --- /dev/null +++ b/backend/models/EmailVerificationCode.js @@ -0,0 +1,40 @@ +const mongoose = require('mongoose'); + +const EmailVerificationCodeSchema = new mongoose.Schema({ + email: { + type: String, + required: true, + lowercase: true, + trim: true, + index: true + }, + code: { + type: String, + required: true + }, + purpose: { + type: String, + enum: ['registration', 'password_reset'], + default: 'registration' + }, + expiresAt: { + type: Date, + required: true, + index: { expireAfterSeconds: 0 } // Автоматическое удаление истекших кодов + }, + verified: { + type: Boolean, + default: false + }, + attempts: { + type: Number, + default: 0 + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('EmailVerificationCode', EmailVerificationCodeSchema); + diff --git a/backend/models/User.js b/backend/models/User.js index dc1692f..e78b256 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -112,6 +112,22 @@ const UserSchema = new mongoose.Schema({ type: Number, default: 0 }, + // Email и пароль для авторизации через логин/пароль + email: { + type: String, + lowercase: true, + trim: true, + sparse: true, // Разрешить null, но если есть - должен быть уникальным + index: true + }, + passwordHash: { + type: String, + select: false // Не возвращать по умолчанию + }, + emailVerified: { + type: Boolean, + default: false + }, createdAt: { type: Date, default: Date.now diff --git a/backend/routes/modApp.js b/backend/routes/modApp.js index 3c6fa50..7f9bfc6 100644 --- a/backend/routes/modApp.js +++ b/backend/routes/modApp.js @@ -3,7 +3,7 @@ const router = express.Router(); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { authenticateModeration } = require('../middleware/auth'); +const { authenticateModerationFlexible } = require('../middleware/auth'); const { logSecurityEvent } = require('../middleware/logger'); const { uploadChannelMedia, cleanupOnError } = require('../middleware/upload'); const { deleteFile } = require('../utils/minio'); @@ -14,18 +14,28 @@ const ModerationAdmin = require('../models/ModerationAdmin'); const AdminConfirmation = require('../models/AdminConfirmation'); const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin'); const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor'); +const { sendAdminConfirmationCode } = require('../utils/email'); const config = require('../config'); const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []); +// Проверка доступа к модерации (только для модераторов и админов) const requireModerationAccess = async (req, res, next) => { + // Проверить роль пользователя + if (!req.user || !['moderator', 'admin'].includes(req.user.role)) { + return res.status(403).json({ error: 'Недостаточно прав для модерации' }); + } + + // Для JWT авторизации (без Telegram) - достаточно проверки роли + if (!req.user.telegramId) { + req.isModerationAdmin = true; + req.isOwner = req.user.role === 'admin'; + return next(); + } + const username = normalizeUsername(req.user?.username); const telegramId = req.user?.telegramId; - if (!username || !telegramId) { - return res.status(401).json({ error: 'Требуется авторизация' }); - } - if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') { req.isModerationAdmin = true; req.isOwner = true; @@ -49,20 +59,25 @@ const requireOwner = (req, res, next) => { next(); }; -const serializeUser = (user) => ({ - id: user._id, - username: user.username, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - banned: user.banned, - bannedUntil: user.bannedUntil, - lastActiveAt: user.lastActiveAt, - createdAt: user.createdAt, - referralsCount: user.referralsCount || 0 -}); +const serializeUser = (user) => { + if (!user) return null; + return { + id: user._id, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + banned: user.banned, + bannedUntil: user.bannedUntil, + lastActiveAt: user.lastActiveAt, + createdAt: user.createdAt, + referralsCount: user.referralsCount || 0, + // passwordHash никогда не возвращается (уже select: false в модели) + // email не возвращается для безопасности + }; +}; -router.post('/auth/verify', authenticateModeration, requireModerationAccess, async (req, res) => { +router.post('/auth/verify', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const admins = await listAdmins(); res.json({ @@ -79,7 +94,7 @@ router.post('/auth/verify', authenticateModeration, requireModerationAccess, asy }); }); -router.get('/users', authenticateModeration, requireModerationAccess, async (req, res) => { +router.get('/users', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { filter = 'active', page = 1, limit = 50 } = req.query; const pageNum = Math.max(parseInt(page, 10) || 1, 1); const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200); @@ -119,7 +134,7 @@ router.get('/users', authenticateModeration, requireModerationAccess, async (req }); }); -router.put('/users/:id/ban', authenticateModeration, requireModerationAccess, async (req, res) => { +router.put('/users/:id/ban', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { banned, days } = req.body; const user = await User.findById(req.params.id); @@ -139,7 +154,7 @@ router.put('/users/:id/ban', authenticateModeration, requireModerationAccess, as res.json({ user: serializeUser(user) }); }); -router.get('/posts', authenticateModeration, requireModerationAccess, async (req, res) => { +router.get('/posts', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { page = 1, limit = 20, author, tag } = req.query; const pageNum = Math.max(parseInt(page, 10) || 1, 1); const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); @@ -189,7 +204,7 @@ router.get('/posts', authenticateModeration, requireModerationAccess, async (req }); // Получить пост с комментариями -router.get('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => { +router.get('/posts/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { try { const post = await Post.findById(req.params.id) .populate('author', 'username firstName lastName photoUrl') @@ -221,7 +236,7 @@ router.get('/posts/:id', authenticateModeration, requireModerationAccess, async }); // Удалить комментарий (модераторский интерфейс) -router.delete('/posts/:postId/comments/:commentId', authenticateModeration, requireModerationAccess, async (req, res) => { +router.delete('/posts/:postId/comments/:commentId', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { try { const post = await Post.findById(req.params.postId); @@ -245,7 +260,7 @@ router.delete('/posts/:postId/comments/:commentId', authenticateModeration, requ } }); -router.put('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => { +router.put('/posts/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { content, hashtags, tags, isNSFW, isArt } = req.body; const post = await Post.findById(req.params.id).populate('author'); @@ -334,7 +349,7 @@ router.put('/posts/:id', authenticateModeration, requireModerationAccess, async }); }); -router.delete('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => { +router.delete('/posts/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const post = await Post.findById(req.params.id); if (!post) { return res.status(404).json({ error: 'Пост не найден' }); @@ -376,7 +391,7 @@ router.delete('/posts/:id', authenticateModeration, requireModerationAccess, asy res.json({ success: true }); }); -router.delete('/posts/:id/images/:index', authenticateModeration, requireModerationAccess, async (req, res) => { +router.delete('/posts/:id/images/:index', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { id, index } = req.params; const idx = parseInt(index, 10); @@ -409,7 +424,7 @@ router.delete('/posts/:id/images/:index', authenticateModeration, requireModerat res.json({ images: post.images }); }); -router.post('/posts/:id/ban', authenticateModeration, requireModerationAccess, async (req, res) => { +router.post('/posts/:id/ban', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { id } = req.params; const { days = 7 } = req.body; @@ -426,7 +441,7 @@ router.post('/posts/:id/ban', authenticateModeration, requireModerationAccess, a res.json({ user: serializeUser(post.author) }); }); -router.get('/reports', authenticateModeration, requireModerationAccess, async (req, res) => { +router.get('/reports', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { page = 1, limit = 30, status = 'pending' } = req.query; const pageNum = Math.max(parseInt(page, 10) || 1, 1); const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100); @@ -473,7 +488,7 @@ router.get('/reports', authenticateModeration, requireModerationAccess, async (r }); }); -router.put('/reports/:id', authenticateModeration, requireModerationAccess, async (req, res) => { +router.put('/reports/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { const { status = 'reviewed' } = req.body; const report = await Report.findById(req.params.id); @@ -491,7 +506,7 @@ router.put('/reports/:id', authenticateModeration, requireModerationAccess, asyn // ========== УПРАВЛЕНИЕ АДМИНАМИ ========== // Получить список всех админов -router.get('/admins', authenticateModeration, requireModerationAccess, async (req, res) => { +router.get('/admins', authenticateModerationFlexible, requireModerationAccess, async (req, res) => { try { const admins = await ModerationAdmin.find().sort({ adminNumber: 1 }); res.json({ @@ -513,7 +528,7 @@ router.get('/admins', authenticateModeration, requireModerationAccess, async (re }); // Инициировать добавление админа (только для владельца) -router.post('/admins/initiate-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { +router.post('/admins/initiate-add', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => { try { const { userId, adminNumber } = req.body; @@ -554,20 +569,34 @@ router.post('/admins/initiate-add', authenticateModeration, requireModerationAcc action: 'add' }); - // Отправить код владельцу (req.user - это ты) - await sendMessageToUser( - req.user.telegramId, - `Подтверждение назначения админом\n\n` + - `Назначаете пользователя @${user.username} (${user.firstName}) админом.\n` + - `Номер админа: ${adminNumber}\n\n` + - `Код подтверждения:\n` + - `${code}\n\n` + - `Код действителен 5 минут.` - ); + // Отправить код на email владельца + try { + await sendAdminConfirmationCode(code, 'add', { + username: user.username, + firstName: user.firstName, + adminNumber: adminNumber + }); + } catch (emailError) { + console.error('Ошибка отправки кода на email:', emailError); + // Fallback - отправить в Telegram если email не работает + try { + await sendMessageToUser( + req.user.telegramId, + `Подтверждение назначения админом\n\n` + + `Назначаете пользователя @${user.username} (${user.firstName}) админом.\n` + + `Номер админа: ${adminNumber}\n\n` + + `Код подтверждения:\n` + + `${code}\n\n` + + `Код действителен 5 минут.` + ); + } catch (telegramError) { + console.error('Ошибка отправки кода в Telegram:', telegramError); + } + } res.json({ success: true, - message: 'Код подтверждения отправлен вам в бот', + message: 'Код подтверждения отправлен на email владельца', username: user.username }); } catch (error) { @@ -577,7 +606,7 @@ router.post('/admins/initiate-add', authenticateModeration, requireModerationAcc }); // Подтвердить добавление админа -router.post('/admins/confirm-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { +router.post('/admins/confirm-add', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => { try { const { userId, code } = req.body; @@ -651,7 +680,7 @@ router.post('/admins/confirm-add', authenticateModeration, requireModerationAcce }); // Инициировать удаление админа (только для владельца) -router.post('/admins/initiate-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { +router.post('/admins/initiate-remove', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => { try { const { adminId } = req.body; @@ -675,20 +704,34 @@ router.post('/admins/initiate-remove', authenticateModeration, requireModeration action: 'remove' }); - // Отправить код владельцу (req.user - это ты) - await sendMessageToUser( - req.user.telegramId, - `Подтверждение снятия админа\n\n` + - `Снимаете пользователя @${admin.username} (${admin.firstName}) с должности админа.\n` + - `Номер админа: ${admin.adminNumber}\n\n` + - `Код подтверждения:\n` + - `${code}\n\n` + - `Код действителен 5 минут.` - ); + // Отправить код на email владельца + try { + await sendAdminConfirmationCode(code, 'remove', { + username: admin.username, + firstName: admin.firstName, + adminNumber: admin.adminNumber + }); + } catch (emailError) { + console.error('Ошибка отправки кода на email:', emailError); + // Fallback - отправить в Telegram если email не работает + try { + await sendMessageToUser( + req.user.telegramId, + `Подтверждение снятия админа\n\n` + + `Снимаете пользователя @${admin.username} (${admin.firstName}) с должности админа.\n` + + `Номер админа: ${admin.adminNumber}\n\n` + + `Код подтверждения:\n` + + `${code}\n\n` + + `Код действителен 5 минут.` + ); + } catch (telegramError) { + console.error('Ошибка отправки кода в Telegram:', telegramError); + } + } res.json({ success: true, - message: 'Код подтверждения отправлен вам в бот', + message: 'Код подтверждения отправлен на email владельца', username: admin.username }); } catch (error) { @@ -698,7 +741,7 @@ router.post('/admins/initiate-remove', authenticateModeration, requireModeration }); // Подтвердить удаление админа -router.post('/admins/confirm-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { +router.post('/admins/confirm-remove', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => { try { const { adminId, code } = req.body; @@ -750,7 +793,7 @@ router.post('/admins/confirm-remove', authenticateModeration, requireModerationA router.post( '/channel/publish', - authenticateModeration, + authenticateModerationFlexible, requireModerationAccess, uploadChannelMedia, async (req, res) => { diff --git a/backend/routes/moderationAuth.js b/backend/routes/moderationAuth.js new file mode 100644 index 0000000..b9d1ae1 --- /dev/null +++ b/backend/routes/moderationAuth.js @@ -0,0 +1,413 @@ +const express = require('express'); +const router = express.Router(); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const User = require('../models/User'); +const EmailVerificationCode = require('../models/EmailVerificationCode'); +const { sendVerificationCode } = require('../utils/email'); +const { signAuthTokens, setAuthCookies, clearAuthCookies, verifyAccessToken } = require('../utils/tokens'); +const { logSecurityEvent } = require('../middleware/logger'); +const { authenticateModeration } = require('../middleware/auth'); +const { isEmail } = require('validator'); + +// Rate limiting для авторизации +const rateLimit = require('express-rate-limit'); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 минут + max: 5, // 5 попыток + message: 'Слишком много попыток авторизации. Попробуйте позже.' +}); + +const codeLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 минута + max: 1, // 1 запрос в минуту + message: 'Подождите минуту перед следующим запросом кода.' +}); + +// Отправка кода подтверждения на email +router.post('/send-code', codeLimiter, async (req, res) => { + try { + const { email } = req.body; + + if (!email || !isEmail(email)) { + return res.status(400).json({ error: 'Неверный email адрес' }); + } + + // Проверить, есть ли уже пользователь с этим email (но только если он модератор/админ) + const existingUser = await User.findOne({ + email: email.toLowerCase(), + role: { $in: ['moderator', 'admin'] } + }); + + if (!existingUser) { + return res.status(403).json({ + error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.' + }); + } + + // Генерировать 6-значный код + const code = crypto.randomInt(100000, 999999).toString(); + + // Удалить старые коды для этого email + await EmailVerificationCode.deleteMany({ + email: email.toLowerCase(), + purpose: 'registration' + }); + + // Сохранить новый код (действителен 15 минут) + const verificationCode = new EmailVerificationCode({ + email: email.toLowerCase(), + code, + purpose: 'registration', + expiresAt: new Date(Date.now() + 15 * 60 * 1000) // 15 минут + }); + + await verificationCode.save(); + + // Отправить код на email + try { + await sendVerificationCode(email, code); + res.json({ + success: true, + message: 'Код подтверждения отправлен на email' + }); + } catch (emailError) { + console.error('Ошибка отправки email:', emailError); + await EmailVerificationCode.deleteOne({ _id: verificationCode._id }); + return res.status(500).json({ + error: 'Не удалось отправить код на email. Проверьте настройки email сервера.' + }); + } + } catch (error) { + console.error('Ошибка отправки кода:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Регистрация с кодом подтверждения +router.post('/register', authLimiter, async (req, res) => { + try { + const { email, code, password, username } = req.body; + + if (!email || !code || !password || !username) { + return res.status(400).json({ error: 'Все поля обязательны' }); + } + + if (!isEmail(email)) { + return res.status(400).json({ error: 'Неверный email адрес' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' }); + } + + // Найти код подтверждения + const verificationCode = await EmailVerificationCode.findOne({ + email: email.toLowerCase(), + code, + purpose: 'registration', + verified: false + }); + + if (!verificationCode) { + return res.status(400).json({ error: 'Неверный или истекший код' }); + } + + // Проверить срок действия + if (new Date() > verificationCode.expiresAt) { + await EmailVerificationCode.deleteOne({ _id: verificationCode._id }); + return res.status(400).json({ error: 'Код истек. Запросите новый.' }); + } + + // Найти пользователя (должен быть создан администратором) + const user = await User.findOne({ + email: email.toLowerCase(), + role: { $in: ['moderator', 'admin'] } + }); + + if (!user) { + return res.status(403).json({ + error: 'Регистрация недоступна. Обратитесь к администратору.' + }); + } + + // Если у пользователя уже есть пароль - ошибка + if (user.passwordHash) { + return res.status(400).json({ + error: 'Аккаунт уже зарегистрирован. Используйте вход по паролю.' + }); + } + + // Захешировать пароль + const passwordHash = await bcrypt.hash(password, 10); + + // Обновить пользователя + user.passwordHash = passwordHash; + user.emailVerified = true; + user.username = username || user.username; + await user.save(); + + // Пометить код как использованный + verificationCode.verified = true; + await verificationCode.save(); + + // Генерировать токены + const tokens = signAuthTokens(user); + + // Установить cookies + setAuthCookies(res, tokens); + + res.json({ + success: true, + user: { + id: user._id, + username: user.username, + role: user.role + }, + accessToken: tokens.accessToken + }); + } catch (error) { + console.error('Ошибка регистрации:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Авторизация по email и паролю +router.post('/login', authLimiter, async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email и пароль обязательны' }); + } + + if (!isEmail(email)) { + return res.status(400).json({ error: 'Неверный email адрес' }); + } + + // Найти пользователя с паролем (только модераторы и админы) + const user = await User.findOne({ + email: email.toLowerCase(), + passwordHash: { $exists: true, $ne: null }, + role: { $in: ['moderator', 'admin'] } + }).select('+passwordHash'); + + if (!user) { + logSecurityEvent('MODERATION_LOGIN_FAILED', req, { email: email.toLowerCase() }); + return res.status(401).json({ error: 'Неверный email или пароль' }); + } + + if (user.banned) { + return res.status(403).json({ error: 'Аккаунт заблокирован' }); + } + + // Проверить пароль + const isPasswordValid = await bcrypt.compare(password, user.passwordHash); + + if (!isPasswordValid) { + logSecurityEvent('MODERATION_LOGIN_FAILED', req, { email: email.toLowerCase(), userId: user._id }); + return res.status(401).json({ error: 'Неверный email или пароль' }); + } + + // Обновить время последней активности + user.lastActiveAt = new Date(); + await user.save(); + + // Генерировать токены + const tokens = signAuthTokens(user); + + // Установить cookies + setAuthCookies(res, tokens); + + logSecurityEvent('MODERATION_LOGIN_SUCCESS', req, { userId: user._id, email: user.email }); + + // Email не возвращаем в ответе для безопасности + res.json({ + success: true, + user: { + id: user._id, + username: user.username, + role: user.role, + telegramId: user.telegramId + }, + accessToken: tokens.accessToken + }); + } catch (error) { + console.error('Ошибка авторизации:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Авторизация через Telegram Login Widget (для обычного браузера) +router.post('/telegram-widget', authLimiter, async (req, res) => { + try { + const { id, first_name, last_name, username, photo_url, auth_date, hash } = req.body; + + if (!id || !hash || !auth_date) { + return res.status(400).json({ error: 'Неполные данные от Telegram Login Widget' }); + } + + // Проверить подпись (базовая проверка) + // В production нужно проверить hash через Bot API + // Для модерации используем упрощенную проверку - ищем пользователя по telegramId + + const user = await User.findOne({ telegramId: id.toString() }); + + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден. Сначала зарегистрируйтесь через бота.' }); + } + + if (!['moderator', 'admin'].includes(user.role)) { + return res.status(403).json({ error: 'Доступ запрещен. У вас нет прав модератора.' }); + } + + if (user.banned) { + return res.status(403).json({ error: 'Аккаунт заблокирован' }); + } + + // Обновить данные пользователя из виджета + if (username && !user.username) { + user.username = username; + } + if (first_name && !user.firstName) { + user.firstName = first_name; + } + if (last_name && !user.lastName) { + user.lastName = last_name; + } + if (photo_url && !user.photoUrl) { + user.photoUrl = photo_url; + } + + user.lastActiveAt = new Date(); + await user.save(); + + // Генерировать JWT токены + const tokens = signAuthTokens(user); + setAuthCookies(res, tokens); + + logSecurityEvent('MODERATION_TELEGRAM_WIDGET_LOGIN_SUCCESS', req, { userId: user._id }); + + res.json({ + success: true, + user: { + id: user._id, + username: user.username, + role: user.role, + telegramId: user.telegramId + }, + accessToken: tokens.accessToken + }); + } catch (error) { + console.error('Ошибка авторизации через Telegram Widget:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Авторизация через Telegram (для модерации) +router.post('/telegram', authLimiter, authenticateModeration, async (req, res) => { + try { + const user = req.user; + + if (!user || !['moderator', 'admin'].includes(user.role)) { + return res.status(403).json({ error: 'Доступ запрещен' }); + } + + if (user.banned) { + return res.status(403).json({ error: 'Аккаунт заблокирован' }); + } + + // Обновить время последней активности + user.lastActiveAt = new Date(); + await user.save(); + + // Генерировать токены + const tokens = signAuthTokens(user); + + // Установить cookies + setAuthCookies(res, tokens); + + logSecurityEvent('MODERATION_TELEGRAM_LOGIN_SUCCESS', req, { userId: user._id }); + + // Email не возвращаем в ответе для безопасности + res.json({ + success: true, + user: { + id: user._id, + username: user.username, + role: user.role, + telegramId: user.telegramId + }, + accessToken: tokens.accessToken + }); + } catch (error) { + console.error('Ошибка авторизации через Telegram:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Выход +router.post('/logout', (req, res) => { + clearAuthCookies(res); + res.json({ success: true }); +}); + +// Проверка текущей сессии +router.get('/me', async (req, res) => { + try { + // Получить токен из заголовка или cookie + let token = null; + + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.slice(7); + } else if (req.cookies && req.cookies[require('../utils/tokens').ACCESS_COOKIE]) { + token = req.cookies[require('../utils/tokens').ACCESS_COOKIE]; + } + + if (!token) { + return res.status(401).json({ error: 'Не авторизован' }); + } + + // Проверить токен + let payload; + try { + payload = verifyAccessToken(token); + } catch (error) { + return res.status(401).json({ error: 'Неверный токен' }); + } + + // Найти пользователя + const user = await User.findById(payload.userId); + + if (!user) { + return res.status(401).json({ error: 'Пользователь не найден' }); + } + + if (user.banned) { + return res.status(403).json({ error: 'Аккаунт заблокирован' }); + } + + // Проверить роль (только модераторы и админы) + if (!['moderator', 'admin'].includes(user.role)) { + return res.status(403).json({ error: 'Доступ запрещен' }); + } + + res.json({ + success: true, + user: { + id: user._id, + username: user.username, + role: user.role, + telegramId: user.telegramId + } + }); + } catch (error) { + console.error('Ошибка проверки сессии:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +module.exports = router; + diff --git a/backend/server.js b/backend/server.js index 2edb11e..065f177 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,7 @@ const express = require('express'); const mongoose = require('mongoose'); const cors = require('cors'); +const cookieParser = require('cookie-parser'); const dotenv = require('dotenv'); const path = require('path'); const http = require('http'); @@ -54,6 +55,9 @@ const corsOptions = { app.use(cors(corsOptions)); +// Cookie parser для JWT токенов в cookies +app.use(cookieParser()); + // Body parsing с ограничениями app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); @@ -244,6 +248,7 @@ app.use('/api/moderation', require('./routes/moderation')); app.use('/api/statistics', require('./routes/statistics')); app.use('/api/bot', require('./routes/bot')); app.use('/api/mod-app', require('./routes/modApp')); +app.use('/api/moderation-auth', require('./routes/moderationAuth')); app.use('/api/minio', require('./routes/minio-test')); app.use('/api/tags', require('./routes/tags')); diff --git a/backend/utils/email.js b/backend/utils/email.js new file mode 100644 index 0000000..d98e324 --- /dev/null +++ b/backend/utils/email.js @@ -0,0 +1,184 @@ +const AWS = require('aws-sdk'); +const nodemailer = require('nodemailer'); +const config = require('../config'); + +// Инициализация AWS SES +let sesClient = null; +let transporter = null; + +const initializeEmailService = () => { + const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; // aws, yandex, smtp + + if (emailProvider === 'aws' && config.email?.aws) { + sesClient = new AWS.SES({ + accessKeyId: config.email.aws.accessKeyId, + secretAccessKey: config.email.aws.secretAccessKey, + region: config.email.aws.region || 'us-east-1' + }); + } else if (emailProvider === 'yandex' || emailProvider === 'smtp') { + const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {}; + + transporter = nodemailer.createTransport({ + host: emailConfig.host || process.env.SMTP_HOST, + port: emailConfig.port || parseInt(process.env.SMTP_PORT || '587', 10), + secure: emailConfig.secure === true || process.env.SMTP_SECURE === 'true', + auth: { + user: emailConfig.user || process.env.SMTP_USER, + pass: emailConfig.password || process.env.SMTP_PASSWORD + } + }); + } +}; + +// Генерация HTML письма с кодом +const generateVerificationEmail = (code) => { + return ` + + + + + + + +
+

Код подтверждения

+

Ваш код для регистрации в Nakama:

+
${code}
+

Код действителен в течение 15 минут.

+ +
+ + + `; +}; + +const sendEmail = async (to, subject, html, text) => { + try { + const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; + const fromEmail = process.env.EMAIL_FROM || config.email?.from || 'noreply@nakama.guru'; + + if (emailProvider === 'aws' && sesClient) { + // Отправка через AWS SES + const params = { + Source: fromEmail, + Destination: { + ToAddresses: [to] + }, + Message: { + Subject: { + Data: subject, + Charset: 'UTF-8' + }, + Body: { + Html: { + Data: html, + Charset: 'UTF-8' + }, + Text: { + Data: text || html.replace(/<[^>]*>/g, ''), + Charset: 'UTF-8' + } + } + } + }; + + const result = await sesClient.sendEmail(params).promise(); + return { success: true, messageId: result.MessageId }; + } else if (transporter) { + // Отправка через SMTP (Yandex, Gmail и т.д.) + const info = await transporter.sendMail({ + from: fromEmail, + to, + subject, + html, + text: text || html.replace(/<[^>]*>/g, '') + }); + + return { success: true, messageId: info.messageId }; + } else { + throw new Error('Email service not configured'); + } + } catch (error) { + console.error('Ошибка отправки email:', error); + throw error; + } +}; + +const sendVerificationCode = async (email, code) => { + const subject = 'Код подтверждения регистрации - Nakama'; + const html = generateVerificationEmail(code); + const text = `Ваш код подтверждения: ${code}. Код действителен 15 минут.`; + + return await sendEmail(email, subject, html, text); +}; + +// Генерация HTML письма с кодом для админа +const generateAdminConfirmationEmail = (code, action, userInfo) => { + const actionText = action === 'add' ? 'добавления админа' : 'удаления админа'; + const userDetails = userInfo ? ` +

Пользователь: @${userInfo.username} (${userInfo.firstName})

+ ${userInfo.adminNumber ? `

Номер админа: ${userInfo.adminNumber}

` : ''} + ` : ''; + + return ` + + + + + + + +
+

Подтверждение ${actionText}

+ ${userDetails} +
+

Код подтверждения:

+
${code}
+

Код действителен в течение 5 минут.

+
+ +
+ + + `; +}; + +const sendAdminConfirmationCode = async (code, action, userInfo) => { + const ownerEmail = config.ownerEmail || process.env.OWNER_EMAIL || 'aaem9848@gmail.com'; + const actionText = action === 'add' ? 'добавления админа' : 'удаления админа'; + const subject = `Код подтверждения ${actionText} - Nakama Moderation`; + const html = generateAdminConfirmationEmail(code, action, userInfo); + const text = `Код подтверждения ${actionText}: ${code}\n\nПользователь: @${userInfo?.username || 'не указан'}\nКод действителен 5 минут.`; + + return await sendEmail(ownerEmail, subject, html, text); +}; + +// Инициализация при загрузке модуля +initializeEmailService(); + +module.exports = { + sendEmail, + sendVerificationCode, + sendAdminConfirmationCode, + initializeEmailService +}; + diff --git a/backend/utils/tokens.js b/backend/utils/tokens.js index 9a0e656..a5ef75c 100644 --- a/backend/utils/tokens.js +++ b/backend/utils/tokens.js @@ -7,7 +7,8 @@ const REFRESH_COOKIE = config.jwt.refreshCookieName; const buildPayload = (user) => ({ userId: user._id.toString(), telegramId: user.telegramId, - role: user.role + role: user.role, + // Не включаем email или passwordHash в JWT токен для безопасности }); const signAccessToken = (user) => diff --git a/docker-compose.yml b/docker-compose.yml index a1faffa..5c17b3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,20 +62,87 @@ services: depends_on: - backend - moderation: + moderation-backend: + build: + context: . + dockerfile: Dockerfile.moderation-backend + container_name: nakama-moderation-backend + restart: unless-stopped + expose: + - "3001" + environment: + - NODE_ENV=production + - PORT=3001 + - MODERATION_PORT=3001 + - MONGODB_URI=${MONGODB_URI:-mongodb://103.80.87.247:27017/nakama} + - MODERATION_BOT_TOKEN=${MODERATION_BOT_TOKEN} + - MODERATION_OWNER_USERNAMES=${MODERATION_OWNER_USERNAMES:-glpshchn00} + - MODERATION_CORS_ORIGIN=${MODERATION_CORS_ORIGIN:-https://moderation.nkm.guru} + - JWT_SECRET=${JWT_SECRET} + - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} + - REDIS_URL=${REDIS_URL} + - EMAIL_PROVIDER=${EMAIL_PROVIDER:-aws} + - EMAIL_FROM=${EMAIL_FROM:-noreply@nakama.guru} + - AWS_SES_ACCESS_KEY_ID=${AWS_SES_ACCESS_KEY_ID} + - AWS_SES_SECRET_ACCESS_KEY=${AWS_SES_SECRET_ACCESS_KEY} + - AWS_SES_REGION=${AWS_SES_REGION:-us-east-1} + - YANDEX_SMTP_USER=${YANDEX_SMTP_USER} + - YANDEX_SMTP_PASSWORD=${YANDEX_SMTP_PASSWORD} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_SECURE=${SMTP_SECURE:-false} + # MinIO Configuration + - MINIO_ENABLED=${MINIO_ENABLED:-true} + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-103.80.87.247} + - MINIO_PORT=${MINIO_PORT:-9000} + - MINIO_USE_SSL=${MINIO_USE_SSL:-false} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET=${MINIO_BUCKET:-nakama-media} + networks: + - nakama-network + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + moderation-frontend: build: context: . dockerfile: Dockerfile.moderation args: - VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api} - container_name: nakama-moderation + VITE_API_URL: ${VITE_MODERATION_API_URL:-https://moderation.nkm.guru/api} + container_name: nakama-moderation-frontend restart: unless-stopped - ports: - - "5174:80" + expose: + - "80" networks: - nakama-network depends_on: - - backend + - moderation-backend + + nginx-moderation: + image: nginx:alpine + container_name: nakama-nginx-moderation + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx-moderation-production.conf:/etc/nginx/conf.d/default.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - /var/www/certbot:/var/www/certbot:ro + - /var/log/nginx:/var/log/nginx + networks: + - nakama-network + depends_on: + - moderation-backend + - moderation-frontend # MongoDB находится на удаленном сервере (103.80.87.247:27017) # Локальный MongoDB контейнер не нужен diff --git a/moderation/DEPLOY.md b/moderation/DEPLOY.md new file mode 100644 index 0000000..b10271c --- /dev/null +++ b/moderation/DEPLOY.md @@ -0,0 +1,140 @@ +# Деплой модераторского сайта + +## Система авторизации + +Модераторский сайт поддерживает два способа авторизации: + +1. **Telegram WebApp** - автоматическая авторизация через Telegram бота +2. **Email/Пароль** - регистрация через код на email и вход по паролю + +## Настройка email сервиса + +### AWS SES + +```bash +EMAIL_PROVIDER=aws +AWS_SES_ACCESS_KEY_ID=your_access_key +AWS_SES_SECRET_ACCESS_KEY=your_secret_key +AWS_SES_REGION=us-east-1 +EMAIL_FROM=noreply@nakama.guru +``` + +### Yandex Cloud + +```bash +EMAIL_PROVIDER=yandex +YANDEX_SMTP_USER=your_email@yandex.ru +YANDEX_SMTP_PASSWORD=your_app_password +EMAIL_FROM=noreply@nakama.guru +``` + +### SMTP (любой провайдер) + +```bash +EMAIL_PROVIDER=smtp +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your_email@example.com +SMTP_PASSWORD=your_password +SMTP_SECURE=false +EMAIL_FROM=noreply@nakama.guru +``` + +## Процесс регистрации + +1. Администратор должен создать пользователя в БД с email и ролью moderator/admin +2. Пользователь вводит email на странице регистрации +3. Система отправляет 6-значный код на email +4. Пользователь вводит код, username и пароль +5. Аккаунт активируется + +## Деплой с Docker + +### 1. Настройка переменных окружения + +Добавьте в `.env`: + +```bash +# Модерация +MODERATION_PORT=3001 +MODERATION_CORS_ORIGIN=https://moderation.nkm.guru +VITE_MODERATION_API_URL=https://moderation.nkm.guru/api + +# Email для кодов подтверждения админа +OWNER_EMAIL=aaem9848@gmail.com + +# Email настройки (выберите один вариант выше) +EMAIL_PROVIDER=aws +# ... остальные настройки email +``` + +### 2. Сборка и запуск + +```bash +# Сборка всех образов +docker-compose build moderation-backend moderation-frontend + +# Запуск сервисов +docker-compose up -d moderation-backend moderation-frontend nginx-moderation + +# Проверка статуса +docker-compose ps +``` + +### 3. Получение SSL сертификата + +```bash +# Получить сертификат (первый раз) +sudo certbot certonly --standalone -d moderation.nkm.guru + +# Автоматическое обновление +sudo certbot renew --dry-run +``` + +### 4. Проверка работы + +```bash +# Проверка бэкенда +curl http://localhost:3001/health + +# Проверка через nginx +curl https://moderation.nkm.guru/api/health + +# Логи +docker-compose logs -f moderation-backend +``` + +## Обновление + +```bash +# Обновить код +git pull + +# Пересобрать и перезапустить +docker-compose build moderation-backend moderation-frontend +docker-compose up -d moderation-backend moderation-frontend + +# Перезагрузить nginx +docker-compose restart nginx-moderation +``` + +## Структура + +- `moderation-backend` - API сервер модерации (порт 3001) +- `moderation-frontend` - Frontend приложение (nginx) +- `nginx-moderation` - Reverse proxy с SSL + +## Troubleshooting + +### Email не отправляется + +1. Проверьте настройки EMAIL_PROVIDER в .env +2. Проверьте логи: `docker-compose logs moderation-backend` +3. Убедитесь что credentials правильные + +### Не работает авторизация + +1. Проверьте JWT_SECRET в .env +2. Проверьте что пользователь создан в БД с правильной ролью +3. Проверьте логи авторизации + diff --git a/moderation/README.md b/moderation/README.md new file mode 100644 index 0000000..33b9708 --- /dev/null +++ b/moderation/README.md @@ -0,0 +1,132 @@ +# Nakama Moderation Panel + +Панель модерации для Nakama - полнофункциональный веб-интерфейс для модераторов. + +## Возможности + +- 📱 **Адаптивный дизайн** - работает на мобильных устройствах и десктопах +- 🌐 **Веб-версия** - доступна как в Telegram WebApp, так и в обычном браузере +- 👥 **Управление пользователями** - просмотр, фильтрация, блокировка пользователей +- 📝 **Управление постами** - редактирование, удаление, пометка артом +- 🚨 **Обработка репортов** - модерация жалоб пользователей +- 💬 **Чат модераторов** - real-time чат для общения между модераторами +- 📢 **Публикация в канал** - публикация контента в Telegram канал +- 👑 **Управление админами** - назначение и снятие администраторов + +## Требования + +- Node.js 18+ +- MongoDB (используется та же БД что и основной проект) +- Доступ к `.env` файлу в корне проекта + +## Установка + +1. Установите зависимости в корне проекта: +```bash +npm install +``` + +2. Установите зависимости фронтенда модерации: +```bash +cd moderation/frontend +npm install +``` + +3. Убедитесь, что в корневом `.env` файле настроены: + - `MONGODB_URI` - URI для подключения к MongoDB + - `MODERATION_BOT_TOKEN` - токен бота для модерации + - `MODERATION_OWNER_USERNAMES` - список владельцев (через запятую) + - `MODERATION_PORT` - порт для бэкенда модерации (по умолчанию 3001) + +## Запуск + +### Development режим + +Запустить бэкенд и фронтенд модерации одновременно: +```bash +npm run mod-dev +``` + +Или отдельно: +```bash +# Бэкенд (порт 3001) +npm run mod-server + +# Фронтенд (порт 5174) +npm run mod-client +``` + +### Production режим + +1. Соберите фронтенд: +```bash +npm run mod-build +``` + +2. Запустите бэкенд: +```bash +npm run mod-start +``` + +## Доступ + +### В Telegram +Откройте бота модерации и перейдите в меню веб-приложения. + +### В браузере +1. Откройте `http://localhost:5174` (development) или ваш production URL +2. Войдите используя initData из Telegram WebApp или токен доступа + +## Структура проекта + +``` +moderation/ +├── backend/ +│ └── server.js # Бэкенд сервер модерации +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Главный компонент +│ │ ├── utils/ +│ │ │ └── api.js # API клиент +│ │ └── styles.css # Стили с адаптивным дизайном +│ └── vite.config.js # Конфигурация Vite +└── README.md +``` + +## Технологии + +- **Backend**: Express.js, использует общие middleware и модели из основного бэкенда +- **Frontend**: React, Vite +- **WebSocket**: Socket.io для real-time чата модераторов +- **Стили**: CSS с адаптивными media queries + +## Переменные окружения + +Модераторский бэкенд использует переменные из корневого `.env`: + +- `MONGODB_URI` - подключение к MongoDB +- `MODERATION_BOT_TOKEN` - токен бота +- `MODERATION_OWNER_USERNAMES` - владельцы системы +- `MODERATION_PORT` - порт бэкенда (по умолчанию 3001) +- `MODERATION_CORS_ORIGIN` - CORS origin (опционально) +- Все остальные переменные из основного `.env` + +## Адаптивный дизайн + +Приложение автоматически адаптируется под разные размеры экранов: + +- **Desktop (>1024px)**: Многоколоночный layout, расширенные элементы управления +- **Tablet (768px-1023px)**: Одноколоночный layout с оптимизированными элементами +- **Mobile (<768px)**: Компактный layout, вертикальное меню, сенсорные элементы + +## Безопасность + +- Все запросы требуют авторизации через Telegram initData +- Используются те же middleware безопасности что и в основном бэкенде +- Rate limiting для защиты от DDoS +- Валидация и санитизация всех входных данных + +## Поддержка + +При возникновении проблем обращайтесь к владельцу системы. + diff --git a/moderation/backend/package.json b/moderation/backend/package.json new file mode 100644 index 0000000..812929b --- /dev/null +++ b/moderation/backend/package.json @@ -0,0 +1,15 @@ +{ + "name": "nakama-moderation-backend", + "version": "1.0.0", + "description": "Nakama Moderation Backend Server", + "main": "server.js", + "scripts": { + "dev": "nodemon server.js", + "start": "node server.js" + }, + "keywords": ["moderation", "admin"], + "author": "", + "license": "MIT", + "dependencies": {} +} + diff --git a/moderation/backend/server.js b/moderation/backend/server.js new file mode 100644 index 0000000..1b03314 --- /dev/null +++ b/moderation/backend/server.js @@ -0,0 +1,153 @@ +const express = require('express'); +const mongoose = require('mongoose'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const dotenv = require('dotenv'); +const path = require('path'); +const http = require('http'); + +// Загрузить переменные окружения из корня проекта +const rootEnvPath = path.resolve(__dirname, '../../.env'); +dotenv.config({ path: rootEnvPath }); + +const config = require('../../backend/config'); +const { initWebSocket } = require('../../backend/websocket'); + +// Security middleware +const { + helmetConfig, + sanitizeMongo, + xssProtection, + hppProtection, + ddosProtection +} = require('../../backend/middleware/security'); +const { sanitizeInput } = require('../../backend/middleware/validator'); +const { requestLogger } = require('../../backend/middleware/logger'); +const { errorHandler, notFoundHandler } = require('../../backend/middleware/errorHandler'); +const { generalLimiter } = require('../../backend/middleware/rateLimiter'); + +const app = express(); +const server = http.createServer(app); + +// Trust proxy для правильного IP +if (config.isProduction()) { + app.set('trust proxy', 1); +} + +// Security headers +app.use(helmetConfig); + +// CORS настройки для модераторского сайта +const corsOptions = { + origin: process.env.MODERATION_CORS_ORIGIN || config.frontendUrl || '*', + credentials: true, + optionsSuccessStatus: 200, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'x-telegram-init-data'], + maxAge: 86400 +}; + +app.use(cors(corsOptions)); + +// Cookie parser для JWT токенов в cookies +app.use(cookieParser()); + +// Body parsing +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Security middleware +app.use(sanitizeMongo); +app.use(xssProtection); +app.use(hppProtection); + +// Input sanitization +app.use(sanitizeInput); + +// Request logging +app.use(requestLogger); + +// DDoS protection +app.use(ddosProtection); + +// Rate limiting +app.use('/api', generalLimiter); + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'moderation', + timestamp: new Date().toISOString() + }); +}); + +// MongoDB подключение (используем ту же БД) +mongoose.connect(config.mongoUri) + .then(async () => { + console.log('✅ MongoDB подключена для модерации'); + + mongoose.connection.on('error', (err) => { + console.error('❌ MongoDB connection error:', err); + }); + + mongoose.connection.on('disconnected', () => { + console.warn('⚠️ MongoDB отключена'); + }); + + mongoose.connection.on('reconnected', () => { + console.log('✅ MongoDB переподключена'); + }); + }) + .catch(err => { + console.error('❌ Не удалось подключиться к MongoDB:', err); + process.exit(1); + }); + +// Routes - используем те же роуты из основного бэкенда +app.use('/api/mod-app', require('../../backend/routes/modApp')); +app.use('/api/moderation-auth', require('../../backend/routes/moderationAuth')); + +// Базовый роут +app.get('/', (req, res) => { + res.json({ message: 'Nakama Moderation API' }); +}); + +// 404 handler +app.use(notFoundHandler); + +// Error handler +app.use(errorHandler); + +// Инициализировать WebSocket +initWebSocket(server); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM получен, закрываем сервер модерации...'); + server.close(() => { + mongoose.connection.close(false, () => { + process.exit(0); + }); + }); +}); + +process.on('SIGINT', () => { + console.log('SIGINT получен, закрываем сервер модерации...'); + server.close(() => { + mongoose.connection.close(false, () => { + process.exit(0); + }); + }); +}); + +const moderationPort = process.env.MODERATION_PORT || 3001; + +server.listen(moderationPort, '0.0.0.0', () => { + console.log('\n' + '='.repeat(60)); + console.log('✅ Сервер модерации запущен'); + console.log(` 🌐 API: http://0.0.0.0:${moderationPort}/api`); + console.log(` 📦 MongoDB: ${config.mongoUri.includes('localhost') ? 'Local' : 'Remote'}`); + console.log('='.repeat(60) + '\n'); +}); + diff --git a/moderation/frontend/index.html b/moderation/frontend/index.html index 5713c21..2168225 100644 --- a/moderation/frontend/index.html +++ b/moderation/frontend/index.html @@ -2,11 +2,35 @@ - + + Nakama Moderation - + + + +
diff --git a/moderation/frontend/src/App.jsx b/moderation/frontend/src/App.jsx index e2be27d..bf5cf43 100644 --- a/moderation/frontend/src/App.jsx +++ b/moderation/frontend/src/App.jsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { - verifyAuth, + getCurrentUser, + sendVerificationCode, + registerWithCode, + login, + loginTelegram, + logout, fetchUsers, banUser, fetchPosts, @@ -17,7 +22,8 @@ import { initiateRemoveAdmin, confirmRemoveAdmin, getPostComments, - deleteComment + deleteComment, + getApiUrl } from './utils/api'; import { io } from 'socket.io-client'; import { @@ -118,43 +124,135 @@ export default function App() { // Comments modal const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] } const [commentsLoading, setCommentsLoading] = useState(false); + + // Форма авторизации + const [authForm, setAuthForm] = useState({ + step: 'login', // 'login', 'register-step1', 'register-step2' + email: '', + password: '', + code: '', + username: '', + showPassword: false + }); + const [authLoading, setAuthLoading] = useState(false); useEffect(() => { let cancelled = false; + // Инициализация Telegram Login Widget для обычного браузера + const initTelegramWidget = () => { + // Глобальная функция для обработки авторизации через виджет + window.onTelegramAuth = async (userData) => { + console.log('Telegram Login Widget данные:', userData); + + try { + setAuthLoading(true); + + // Отправить данные виджета на сервер для создания сессии + const API_URL = getApiUrl(); + const response = await fetch(`${API_URL}/moderation-auth/telegram-widget`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(userData) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Ошибка авторизации'); + } + + const result = await response.json(); + + if (result.accessToken) { + localStorage.setItem('moderation_jwt_token', result.accessToken); + } + + if (result?.user) { + setUser(result.user); + setError(null); + } + } catch (err) { + console.error('Ошибка авторизации через виджет:', err); + setError(err.message || 'Ошибка авторизации через Telegram'); + } finally { + setAuthLoading(false); + } + }; + + // Загрузить виджет скрипт если его нет и есть контейнер + if (!document.querySelector('script[src*="telegram-widget"]')) { + setTimeout(() => { + const widgetContainer = telegramWidgetRef.current; + if (!widgetContainer) return; + + const script = document.createElement('script'); + script.async = true; + script.src = 'https://telegram.org/js/telegram-widget.js?22'; + script.setAttribute('data-telegram-login', 'NakamaSpaceBot'); + script.setAttribute('data-size', 'large'); + script.setAttribute('data-request-access', 'write'); + script.setAttribute('data-onauth', 'onTelegramAuth'); + + widgetContainer.appendChild(script); + }, 100); + } + }; + const init = async () => { try { const telegramApp = window.Telegram?.WebApp; - if (!telegramApp) { - throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).'); + // Если это Telegram WebApp - попробовать авторизацию через Telegram + if (telegramApp && telegramApp.initData) { + telegramApp.disableVerticalSwipes?.(); + telegramApp.expand?.(); + + try { + const result = await loginTelegram(); + if (cancelled) return; + + setUser(result.user); + setError(null); + setLoading(false); + return; + } catch (err) { + console.warn('Telegram авторизация не удалась, пробуем JWT:', err); + } } - if (!telegramApp.initData) { - throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.'); + // Инициализировать виджет для обычного браузера + if (!telegramApp?.initData) { + initTelegramWidget(); } - telegramApp.disableVerticalSwipes?.(); - telegramApp.expand?.(); + // Проверить JWT токен + const jwtToken = localStorage.getItem('moderation_jwt_token'); + if (jwtToken) { + try { + const userData = await getCurrentUser(); + if (cancelled) return; - const userData = await verifyAuth(); - if (cancelled) return; + setUser(userData); + setError(null); + setLoading(false); + return; + } catch (err) { + // Если токен невалиден - очистить + localStorage.removeItem('moderation_jwt_token'); + localStorage.removeItem('moderation_token'); + } + } - setUser(userData); - setError(null); + // Нет токена - показать форму входа + setLoading(false); + setError('login_required'); + } catch (err) { if (cancelled) return; console.error('Ошибка инициализации модератора:', err); - const message = - err?.response?.data?.error || - err?.message || - 'Нет доступа. Убедитесь, что вы добавлены как администратор.'; - setError(message); - } finally { - if (!cancelled) { - setLoading(false); - // Убрана кнопка "Закрыть" - } + setError('login_required'); + setLoading(false); } }; @@ -162,6 +260,9 @@ export default function App() { return () => { cancelled = true; + if (window.onTelegramAuth) { + delete window.onTelegramAuth; + } }; }, []); @@ -297,9 +398,18 @@ export default function App() { return; } - const API_URL = import.meta.env.VITE_API_URL || ( - import.meta.env.PROD ? '/api' : 'http://localhost:3000/api' - ); + // Использовать тот же API URL что и в api.js + const getApiUrl = () => { + if (import.meta.env.VITE_API_URL) { + return import.meta.env.VITE_API_URL; + } + if (import.meta.env.PROD) { + return '/api'; + } + return 'http://localhost:3001/api'; + }; + + const API_URL = getApiUrl(); // Для WebSocket убираем "/api" из base URL, т.к. socket.io слушает на корне const socketBase = API_URL.replace(/\/?api\/?$/, ''); @@ -508,6 +618,95 @@ export default function App() { setPublishState((prev) => ({ ...prev, files })); }; + const handleSendCode = async () => { + if (!authForm.email || !authForm.email.includes('@')) { + setError('Введите корректный email'); + return; + } + + setAuthLoading(true); + try { + await sendVerificationCode(authForm.email); + setAuthForm(prev => ({ ...prev, step: 'register-step2' })); + setError(null); + } catch (err) { + const message = err?.response?.data?.error || err?.message || 'Ошибка отправки кода'; + setError(message); + } finally { + setAuthLoading(false); + } + }; + + const handleRegister = async () => { + if (!authForm.code || !authForm.password || !authForm.username) { + setError('Заполните все поля'); + return; + } + + if (authForm.password.length < 6) { + setError('Пароль должен содержать минимум 6 символов'); + return; + } + + setAuthLoading(true); + try { + const result = await registerWithCode( + authForm.email, + authForm.code, + authForm.password, + authForm.username + ); + setUser(result.user); + setError(null); + setAuthForm({ step: 'login', email: '', password: '', code: '', username: '', showPassword: false }); + } catch (err) { + const message = err?.response?.data?.error || err?.message || 'Ошибка регистрации'; + setError(message); + } finally { + setAuthLoading(false); + } + }; + + const handleLogin = async () => { + if (!authForm.email || !authForm.password) { + setError('Введите email и пароль'); + return; + } + + setAuthLoading(true); + try { + const result = await login(authForm.email, authForm.password); + setUser(result.user); + setError(null); + setAuthForm({ step: 'login', email: '', password: '', code: '', username: '', showPassword: false }); + } catch (err) { + const message = err?.response?.data?.error || err?.message || 'Ошибка авторизации'; + setError(message); + } finally { + setAuthLoading(false); + } + }; + + const handleTelegramLogin = async () => { + const telegramApp = window.Telegram?.WebApp; + if (!telegramApp || !telegramApp.initData) { + setError('Откройте через Telegram бота для авторизации'); + return; + } + + setAuthLoading(true); + try { + const result = await loginTelegram(); + setUser(result.user); + setError(null); + } catch (err) { + const message = err?.response?.data?.error || err?.message || 'Ошибка авторизации через Telegram'; + setError(message); + } finally { + setAuthLoading(false); + } + }; + const renderUsers = () => (
@@ -609,9 +808,19 @@ export default function App() {
{post.images.map((img, idx) => { // Преобразовать относительный путь в абсолютный + const getImageBaseUrl = () => { + if (import.meta.env.VITE_API_URL) { + return import.meta.env.VITE_API_URL.replace('/api', ''); + } + if (import.meta.env.PROD) { + return window.location.origin; + } + return 'http://localhost:3000'; + }; + const imageUrl = img.startsWith('http') ? img - : `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${img}`; + : `${getImageBaseUrl()}${img}`; return (
@@ -697,9 +906,19 @@ export default function App() { {report.post.images?.length > 0 && (
{report.post.images.slice(0, 3).map((img, idx) => { + const getImageBaseUrl = () => { + if (import.meta.env.VITE_API_URL) { + return import.meta.env.VITE_API_URL.replace('/api', ''); + } + if (import.meta.env.PROD) { + return window.location.origin; + } + return 'http://localhost:3000'; + }; + const imageUrl = img.startsWith('http') ? img - : `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${img}`; + : `${getImageBaseUrl()}${img}`; return ( +
+

Вход в модерацию

+ + {/* Telegram авторизация */} + {canUseTelegram && ( + <> + +
+

+ или +

+
+ + )} + + {/* Telegram Login Widget для обычного браузера */} + {!canUseTelegram && ( + <> +
+
+

+ или +

+
+ + )} + + {/* Авторизация по email/паролю */} + {authForm.step === 'login' && ( +
+ + +
+ +
+ +
+ )} + + {/* Регистрация шаг 1 - отправка кода */} + {authForm.step === 'register-step1' && ( +
+

+ Для регистрации необходимо, чтобы администратор добавил ваш email в систему +

+ + + +
+ )} + + {/* Регистрация шаг 2 - ввод кода и пароля */} + {authForm.step === 'register-step2' && ( +
+

+ Код отправлен на {authForm.email} +

+ + + + + +
+ )} + + {error && error !== 'login_required' && ( +
+ {error} +
+ )} +
+
+ ); + } + + if (error && error !== 'login_required') { return (

{error}

+ {error.includes('доступ') && ( + + )}
); } @@ -1063,6 +1549,16 @@ export default function App() {

Nakama Moderation

@{user.username}
+