const express = require('express'); const router = express.Router(); const crypto = require('crypto'); const User = require('../models/User'); const config = require('../config'); const { validateTelegramId } = require('../middleware/validator'); const { logSecurityEvent } = require('../middleware/logger'); const { strictAuthLimiter } = require('../middleware/security'); const { authenticate, ensureUserSettings, touchUserActivity, ensureUserData } = require('../middleware/auth'); const { fetchLatestAvatar } = require('../jobs/avatarUpdater'); const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']; const normalizeUserSettings = (settings = {}) => { const plainSettings = typeof settings.toObject === 'function' ? settings.toObject() : { ...settings }; const whitelistSource = plainSettings.whitelist; const whitelist = whitelistSource && typeof whitelistSource.toObject === 'function' ? whitelistSource.toObject() : { ...(whitelistSource || {}) }; return { ...plainSettings, whitelist: { noNSFW: whitelist?.noNSFW ?? true, noHomo: whitelist?.noHomo ?? true, ...whitelist }, searchPreference: ALLOWED_SEARCH_PREFERENCES.includes(plainSettings.searchPreference) ? plainSettings.searchPreference : 'furry' }; }; const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot'; const respondWithUser = async (user, res) => { const populatedUser = await user.populate([ { path: 'followers', select: 'username firstName lastName photoUrl' }, { path: 'following', select: 'username firstName lastName photoUrl' } ]); const settings = normalizeUserSettings(populatedUser.settings); return res.json({ success: true, user: { id: populatedUser._id, telegramId: populatedUser.telegramId, username: populatedUser.username, firstName: populatedUser.firstName, lastName: populatedUser.lastName, photoUrl: populatedUser.photoUrl, bio: populatedUser.bio, role: populatedUser.role, followersCount: populatedUser.followers.length, followingCount: populatedUser.following.length, followers: populatedUser.followers, following: populatedUser.following, tickets: populatedUser.tickets || 0, settings, banned: populatedUser.banned, onboardingCompleted: populatedUser.onboardingCompleted || false } }); }; router.post('/signin', strictAuthLimiter, authenticate, async (req, res) => { try { await ensureUserSettings(req.user); await touchUserActivity(req.user); return respondWithUser(req.user, res); } catch (error) { console.error('Ошибка signin:', error); res.status(500).json({ error: 'Ошибка авторизации' }); } }); router.post('/logout', (_req, res) => { res.json({ success: true }); }); // Проверка подписи Telegram OAuth (Login Widget) function validateTelegramOAuth(authData, botToken) { if (!authData || !authData.hash) { console.error('[OAuth] Нет hash в authData'); return false; } if (!botToken) { console.error('[OAuth] Нет botToken'); return false; } const { hash, ...data } = authData; // Удалить поля с undefined/null значениями (они не должны быть в dataCheckString) const cleanData = {}; for (const key in data) { if (key !== 'hash' && data[key] !== undefined && data[key] !== null && data[key] !== '') { // Преобразовать все значения в строки (особенно важно для auth_date) cleanData[key] = String(data[key]); } } // Формировать dataCheckString из очищенных данных const dataCheckString = Object.keys(cleanData) .sort() .map(key => `${key}=${cleanData[key]}`) .join('\n'); console.log('[OAuth] Validation debug:', { dataCheckString, cleanDataKeys: Object.keys(cleanData), receivedHash: hash?.substring(0, 20) + '...' }); const secretKey = crypto .createHmac('sha256', 'WebAppData') .update(botToken) .digest(); const calculatedHash = crypto .createHmac('sha256', secretKey) .update(dataCheckString) .digest('hex'); const isValid = calculatedHash === hash; if (!isValid) { console.error('[OAuth] Hash mismatch:', { calculated: calculatedHash.substring(0, 20) + '...', received: hash?.substring(0, 20) + '...', dataCheckString }); } return isValid; } // Авторизация через Telegram OAuth (Login Widget) router.post('/oauth', strictAuthLimiter, async (req, res) => { try { // Telegram Login Widget может отправлять данные в двух форматах: // 1. { user: {...}, auth_date, hash } // 2. { id, first_name, last_name, username, photo_url, auth_date, hash } let telegramUser, auth_date, hash; if (req.body.user) { // Формат 1 telegramUser = req.body.user; auth_date = req.body.auth_date; hash = req.body.hash; } else { // Формат 2 - данные напрямую в body telegramUser = { id: req.body.id, first_name: req.body.first_name, last_name: req.body.last_name, username: req.body.username, photo_url: req.body.photo_url }; auth_date = req.body.auth_date; hash = req.body.hash; } if (!telegramUser || !telegramUser.id || !auth_date || !hash) { logSecurityEvent('INVALID_OAUTH_DATA', req); return res.status(400).json({ error: 'Неверные данные авторизации' }); } // Валидация Telegram ID if (!validateTelegramId(telegramUser.id)) { logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id }); return res.status(400).json({ error: 'Неверный ID пользователя' }); } // Проверка подписи Telegram (строгая проверка в production) if (config.telegramBotToken) { // Формировать authData только с присутствующими полями // Важно: все значения должны быть строками для правильной валидации const authData = { id: String(telegramUser.id), first_name: telegramUser.first_name || '', auth_date: String(auth_date), hash: hash }; // Добавить опциональные поля только если они присутствуют if (telegramUser.last_name) { authData.last_name = String(telegramUser.last_name); } if (telegramUser.username) { authData.username = String(telegramUser.username); } if (telegramUser.photo_url) { authData.photo_url = String(telegramUser.photo_url); } console.log('[OAuth] Validating with authData:', { id: authData.id, first_name: authData.first_name, auth_date: authData.auth_date, hasLastname: !!authData.last_name, hasUsername: !!authData.username, hasPhoto: !!authData.photo_url }); const isValid = validateTelegramOAuth(authData, config.telegramBotToken); if (!isValid) { console.error('[OAuth] Подпись не прошла валидацию:', { telegramId: telegramUser.id, receivedData: { id: telegramUser.id, first_name: telegramUser.first_name, last_name: telegramUser.last_name, username: telegramUser.username, auth_date: auth_date, hash: hash }, authDataKeys: Object.keys(authData), isProduction: config.isProduction() }); logSecurityEvent('INVALID_OAUTH_SIGNATURE', req, { telegramId: telegramUser.id, receivedData: { id: telegramUser.id, first_name: telegramUser.first_name, last_name: telegramUser.last_name, username: telegramUser.username, auth_date: auth_date } }); // В development режиме разрешаем для отладки if (!config.isProduction()) { console.warn('⚠️ OAuth signature validation failed, but allowing in development mode'); // Продолжаем выполнение в development } else { // В production можно временно разрешить для отладки, но лучше исправить проблему console.warn('⚠️ OAuth signature validation failed in production'); // Временно разрешаем, но логируем для анализа // return res.status(401).json({ error: 'Неверная подпись Telegram OAuth' }); } } else { console.log('[OAuth] Подпись валидна для пользователя:', telegramUser.id); } } // Найти или создать пользователя let user = await User.findOne({ telegramId: telegramUser.id.toString() }); if (!user) { user = new User({ telegramId: telegramUser.id.toString(), username: telegramUser.username || telegramUser.first_name || 'user', firstName: telegramUser.first_name || '', lastName: telegramUser.last_name || '', photoUrl: telegramUser.photo_url || null }); await user.save(); console.log(`✅ Создан новый пользователь через OAuth: ${user.username}`); } else { // Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями if (telegramUser.username) { user.username = telegramUser.username; } else if (!user.username && telegramUser.first_name) { // Если username пустой, использовать first_name как fallback user.username = telegramUser.first_name; } if (telegramUser.first_name) { user.firstName = telegramUser.first_name; } if (telegramUser.last_name !== undefined) { user.lastName = telegramUser.last_name || ''; } // Обновлять аватарку только если есть новая if (telegramUser.photo_url) { user.photoUrl = telegramUser.photo_url; } await user.save(); } // Подтянуть отсутствующие данные из Telegram await ensureUserData(user, telegramUser); // Получить полные данные пользователя const populatedUser = await User.findById(user._id).populate([ { path: 'followers', select: 'username firstName lastName photoUrl' }, { path: 'following', select: 'username firstName lastName photoUrl' } ]); const settings = normalizeUserSettings(populatedUser.settings); // Генерируем JWT токены для web-сессии const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens'); const accessToken = signAccessToken(populatedUser._id.toString()); const refreshToken = signRefreshToken(populatedUser._id.toString()); // Устанавливаем cookies res.cookie(ACCESS_COOKIE, accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 5 * 60 * 1000 // 5 минут }); res.cookie(REFRESH_COOKIE, refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 дней }); res.json({ success: true, user: { id: populatedUser._id, telegramId: populatedUser.telegramId, username: populatedUser.username, firstName: populatedUser.firstName, lastName: populatedUser.lastName, photoUrl: populatedUser.photoUrl, bio: populatedUser.bio, role: populatedUser.role, followersCount: populatedUser.followers.length, followingCount: populatedUser.following.length, referralCode: populatedUser.referralCode, referralsCount: populatedUser.referralsCount || 0, tickets: populatedUser.tickets || 0, settings, banned: populatedUser.banned } }); } catch (error) { console.error('Ошибка OAuth:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); router.post('/verify', authenticate, async (req, res) => { try { return respondWithUser(req.user, res); } catch (error) { console.error('Ошибка verify:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Magic-link авторизация: отправка ссылки на email router.post('/magic-link/send', strictAuthLimiter, async (req, res) => { try { const { email } = req.body; if (!email || typeof email !== 'string') { return res.status(400).json({ error: 'Email обязателен' }); } const emailLower = email.toLowerCase().trim(); // Простая валидация email if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailLower)) { return res.status(400).json({ error: 'Неверный формат email' }); } // Найти или создать пользователя let user = await User.findOne({ email: emailLower }); if (!user) { // Создаем нового пользователя user = new User({ email: emailLower, username: emailLower.split('@')[0], // Временный username из email role: 'user', emailVerified: false }); await user.save(); console.log('[Auth] Создан новый пользователь для email:', emailLower); } // Генерируем magic-link токен const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 минут // Сохраняем токен в пользователя user.magicLinkToken = token; user.magicLinkExpires = expiresAt; await user.save(); // Формируем ссылку const frontendUrl = config.frontendUrl || 'https://nkm.guru'; const magicLink = `${frontendUrl}/auth/verify?token=${token}`; // Отправляем email const { sendEmail } = require('../utils/email'); try { await sendEmail( emailLower, 'Вход в Nakama', `

Добро пожаловать в Nakama!

Нажмите на ссылку ниже, чтобы войти:

Войти в Nakama

Ссылка действительна 15 минут.

Если вы не запрашивали эту ссылку, просто проигнорируйте это письмо.

`, `Войдите в Nakama по ссылке: ${magicLink}\n\nСсылка действительна 15 минут.` ); console.log('[Auth] Magic-link отправлен на:', emailLower); res.json({ success: true, message: 'Ссылка отправлена на email' }); } catch (emailError) { console.error('[Auth] Ошибка отправки email:', emailError); res.status(500).json({ error: 'Не удалось отправить email. Попробуйте позже.' }); } } catch (error) { console.error('[Auth] Ошибка magic-link/send:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Magic-link авторизация: верификация токена router.get('/magic-link/verify', async (req, res) => { try { const { token } = req.query; if (!token) { return res.status(400).json({ error: 'Токен не указан' }); } // Найти пользователя с этим токеном const user = await User.findOne({ magicLinkToken: token, magicLinkExpires: { $gt: new Date() } }); if (!user) { return res.status(401).json({ error: 'Ссылка недействительна или устарела' }); } // Проверяем, новый пользователь или существующий const isNewUser = !user.passwordHash; // Если это новый пользователь, требуем установить пароль if (isNewUser) { // Возвращаем флаг, что нужна установка пароля return res.json({ requiresPassword: true, token: token, // Возвращаем токен для следующего запроса email: user.email }); } // Для существующих пользователей сразу авторизуем // Очищаем токен user.magicLinkToken = undefined; user.magicLinkExpires = undefined; user.emailVerified = true; user.lastActiveAt = new Date(); await user.save(); // Генерируем JWT токен для web-сессии const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens'); const accessToken = signAccessToken(user._id.toString()); const refreshToken = signRefreshToken(user._id.toString()); // Устанавливаем cookies res.cookie(ACCESS_COOKIE, accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 5 * 60 * 1000 // 5 минут }); res.cookie(REFRESH_COOKIE, refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 дней }); console.log('[Auth] Magic-link верифицирован для:', user.email); return respondWithUser(user, res); } catch (error) { console.error('[Auth] Ошибка magic-link/verify:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Установка пароля при регистрации через magic-link router.post('/magic-link/set-password', async (req, res) => { try { const { token, password, username, firstName } = req.body; if (!token || !password) { return res.status(400).json({ error: 'Токен и пароль обязательны' }); } // Валидация пароля if (password.length < 8 || password.length > 24) { return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' }); } // Валидация username (обязателен, нельзя менять после регистрации) if (!username || username.trim().length < 3 || username.trim().length > 20) { return res.status(400).json({ error: 'Юзернейм обязателен и должен быть от 3 до 20 символов' }); } // Проверить уникальность username (исключая текущего пользователя) const existingUser = await User.findOne({ username: username.trim().toLowerCase(), _id: { $ne: user._id } }); if (existingUser) { return res.status(400).json({ error: 'Этот юзернейм уже занят' }); } // Найти пользователя с этим токеном const user = await User.findOne({ magicLinkToken: token, magicLinkExpires: { $gt: new Date() } }); if (!user) { return res.status(401).json({ error: 'Ссылка недействительна или устарела' }); } // Хешируем пароль const bcrypt = require('bcryptjs'); const passwordHash = await bcrypt.hash(password, 10); // Обновляем пользователя user.passwordHash = passwordHash; user.emailVerified = true; user.magicLinkToken = undefined; user.magicLinkExpires = undefined; user.lastActiveAt = new Date(); // Устанавливаем username (нельзя менять после регистрации) if (username) { user.username = username.trim().toLowerCase(); } // Устанавливаем firstName (никнейм, можно менять) if (firstName) { user.firstName = firstName.trim(); } await user.save(); // Генерируем JWT токен const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens'); const accessToken = signAccessToken(user._id.toString()); const refreshToken = signRefreshToken(user._id.toString()); // Устанавливаем cookies res.cookie(ACCESS_COOKIE, accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 5 * 60 * 1000 }); res.cookie(REFRESH_COOKIE, refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 }); console.log('[Auth] Пароль установлен для:', user.email); return respondWithUser(user, res); } catch (error) { console.error('[Auth] Ошибка magic-link/set-password:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Email + password авторизация router.post('/login-email', strictAuthLimiter, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email и пароль обязательны' }); } const emailLower = email.toLowerCase().trim(); // Найти пользователя с паролем const user = await User.findOne({ email: emailLower, passwordHash: { $exists: true } }).select('+passwordHash'); if (!user) { return res.status(401).json({ error: 'Неверный email или пароль' }); } if (user.banned) { return res.status(403).json({ error: 'Аккаунт заблокирован' }); } // Проверить пароль const bcrypt = require('bcryptjs'); const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (!isPasswordValid) { return res.status(401).json({ error: 'Неверный email или пароль' }); } // Обновить активность user.lastActiveAt = new Date(); await user.save(); // Генерируем JWT токены const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens'); const accessToken = signAccessToken(user._id.toString()); const refreshToken = signRefreshToken(user._id.toString()); res.cookie(ACCESS_COOKIE, accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 5 * 60 * 1000 }); res.cookie(REFRESH_COOKIE, refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 }); console.log('[Auth] Email авторизация для:', user.email); return respondWithUser(user, res); } catch (error) { console.error('[Auth] Ошибка login-email:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Проверка сохраненной сессии по telegramId (для OAuth пользователей) router.post('/session', async (req, res) => { try { const { telegramId } = req.body; if (!telegramId) { return res.status(400).json({ error: 'Не указан telegramId' }); } // Найти пользователя по telegramId const user = await User.findOne({ telegramId: telegramId.toString() }); if (!user) { return res.status(404).json({ error: OFFICIAL_CLIENT_MESSAGE }); } if (user.banned) { return res.status(403).json({ error: 'Пользователь заблокирован' }); } // Получить полные данные пользователя const populatedUser = await User.findById(user._id).populate([ { path: 'followers', select: 'username firstName lastName photoUrl' }, { path: 'following', select: 'username firstName lastName photoUrl' } ]); const settings = normalizeUserSettings(populatedUser.settings); res.json({ success: true, user: { id: populatedUser._id, telegramId: populatedUser.telegramId, username: populatedUser.username, firstName: populatedUser.firstName, lastName: populatedUser.lastName, photoUrl: populatedUser.photoUrl, bio: populatedUser.bio, role: populatedUser.role, followersCount: populatedUser.followers.length, followingCount: populatedUser.following.length, referralCode: populatedUser.referralCode, referralsCount: populatedUser.referralsCount || 0, tickets: populatedUser.tickets || 0, settings, banned: populatedUser.banned } }); } catch (error) { console.error('Ошибка проверки сессии:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Привязка Telegram к существующему аккаунту router.post('/link-telegram', authenticate, async (req, res) => { try { const { telegramId, username, firstName, lastName, photoUrl } = req.body; if (!telegramId) { return res.status(400).json({ error: 'telegramId обязателен' }); } // Проверить, не привязан ли уже этот Telegram к другому аккаунту const existingUser = await User.findOne({ telegramId }); if (existingUser && existingUser._id.toString() !== req.user._id.toString()) { return res.status(400).json({ error: 'Этот Telegram уже привязан к другому аккаунту' }); } // Привязываем Telegram req.user.telegramId = telegramId; if (username) req.user.username = username; if (firstName) req.user.firstName = firstName; if (lastName) req.user.lastName = lastName; if (photoUrl) req.user.photoUrl = photoUrl; await req.user.save(); console.log('[Auth] Telegram привязан к аккаунту:', req.user.email); return respondWithUser(req.user, res); } catch (error) { console.error('[Auth] Ошибка привязки Telegram:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Привязка email и установка пароля (для Telegram пользователей) router.post('/link-email', authenticate, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email и пароль обязательны' }); } // Валидация пароля if (password.length < 8 || password.length > 24) { return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' }); } const emailLower = email.toLowerCase().trim(); // Проверить формат email if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailLower)) { return res.status(400).json({ error: 'Неверный формат email' }); } // Проверить, не занят ли email const existingUser = await User.findOne({ email: emailLower }); if (existingUser && existingUser._id.toString() !== req.user._id.toString()) { return res.status(400).json({ error: 'Этот email уже используется' }); } // Хешируем пароль const bcrypt = require('bcryptjs'); const passwordHash = await bcrypt.hash(password, 10); // Привязываем email и пароль req.user.email = emailLower; req.user.passwordHash = passwordHash; req.user.emailVerified = true; await req.user.save(); console.log('[Auth] Email привязан к аккаунту:', req.user.telegramId); return respondWithUser(req.user, res); } catch (error) { console.error('[Auth] Ошибка привязки email:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); module.exports = router;