From d6fcfc5c17de831c61f042e8ff721e9b5a1069fc Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Tue, 11 Nov 2025 00:56:36 +0300 Subject: [PATCH] Update files --- backend/config/index.js | 8 ++ backend/middleware/auth.js | 157 ++++++++-------------- backend/routes/auth.js | 90 +++++++++++++ backend/server.js | 2 + backend/utils/telegram.js | 67 ++++++++++ backend/utils/tokens.js | 67 ++++++++++ frontend/src/App.jsx | 187 +++++++-------------------- frontend/src/utils/api.js | 39 +----- moderation/frontend/src/App.jsx | 27 +++- moderation/frontend/src/utils/api.js | 17 +-- package.json | 3 +- 11 files changed, 369 insertions(+), 295 deletions(-) create mode 100644 backend/utils/telegram.js create mode 100644 backend/utils/tokens.js diff --git a/backend/config/index.js b/backend/config/index.js index a0175f0..1d3d710 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -14,6 +14,14 @@ module.exports = { // JWT jwtSecret: process.env.JWT_SECRET || 'nakama_secret_key_change_in_production', + jwt: { + accessSecret: process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || 'nakama_access_secret_change_me', + refreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET || 'nakama_refresh_secret_change_me', + accessExpiresIn: parseInt(process.env.JWT_ACCESS_EXPIRES_IN || '300', 10), // 5 минут + refreshExpiresIn: parseInt(process.env.JWT_REFRESH_EXPIRES_IN || '604800', 10), // 7 дней + accessCookieName: process.env.JWT_ACCESS_COOKIE_NAME || 'nakama_access_token', + refreshCookieName: process.env.JWT_REFRESH_COOKIE_NAME || 'nakama_refresh_token' + }, // Telegram telegramBotToken: process.env.TELEGRAM_BOT_TOKEN, diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index d31cb46..8858c8a 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,12 +1,19 @@ -const crypto = require('crypto'); const User = require('../models/User'); const { validateTelegramId } = require('./validator'); const { logSecurityEvent } = require('./logger'); const config = require('../config'); +const { + ACCESS_COOKIE, + REFRESH_COOKIE, + signAuthTokens, + setAuthCookies, + clearAuthCookies, + verifyAccessToken, + verifyRefreshToken +} = require('../utils/tokens'); const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot'; const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']; -const MAX_AUTH_AGE_SECONDS = 5 * 60; // 5 минут const touchUserActivity = async (user) => { if (!user) return; @@ -49,124 +56,72 @@ const ensureUserSettings = async (user) => { } }; -function validateTelegramWebAppData(initData, botToken) { - if (!botToken) { - throw new Error('TELEGRAM_BOT_TOKEN не настроен'); - } - - const params = new URLSearchParams(initData); - const hash = params.get('hash'); - const authDate = Number(params.get('auth_date')); - - if (!hash) { - throw new Error('Отсутствует hash в initData'); - } - - if (!authDate) { - throw new Error('Отсутствует auth_date в initData'); - } - - const dataCheck = []; - for (const [key, value] of params.entries()) { - if (key === 'hash') continue; - dataCheck.push(`${key}=${value}`); - } - - dataCheck.sort((a, b) => a.localeCompare(b)); - const dataCheckString = dataCheck.join('\n'); - - const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest(); - const calculatedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); - - if (calculatedHash !== hash) { - throw new Error('Неверная подпись initData'); - } - - const now = Math.floor(Date.now() / 1000); - if (Math.abs(now - authDate) > MAX_AUTH_AGE_SECONDS) { - throw new Error('Данные авторизации устарели'); - } - - return params; -} - // Middleware для проверки авторизации const authenticate = async (req, res, next) => { try { - const initData = req.headers['x-telegram-init-data']; + const accessToken = req.cookies[ACCESS_COOKIE]; + const refreshToken = req.cookies[REFRESH_COOKIE]; - if (!initData) { - logSecurityEvent('MISSING_INITDATA', req); - return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); - } + let tokenPayload = null; - if (!config.telegramBotToken) { - logSecurityEvent('MISSING_BOT_TOKEN', req); - if (config.isProduction()) { - return res.status(500).json({ error: 'Сервер некорректно настроен для авторизации через Telegram' }); + if (accessToken) { + try { + tokenPayload = verifyAccessToken(accessToken); + } catch (error) { + if (error.name !== 'TokenExpiredError') { + logSecurityEvent('INVALID_ACCESS_TOKEN', req, { error: error.message }); + clearAuthCookies(res); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); + } } - console.warn('⚠️ TELEGRAM_BOT_TOKEN не установлен, пропускаем проверку подписи (dev режим)'); } - let params; - try { - if (config.telegramBotToken) { - params = validateTelegramWebAppData(initData, config.telegramBotToken); - } else { - params = new URLSearchParams(initData); + if (!tokenPayload && refreshToken) { + try { + const refreshPayload = verifyRefreshToken(refreshToken); + const userForRefresh = await User.findById(refreshPayload.userId); + + if (!userForRefresh) { + clearAuthCookies(res); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); + } + + const tokens = signAuthTokens(userForRefresh); + setAuthCookies(res, tokens); + tokenPayload = verifyAccessToken(tokens.accessToken); + } catch (error) { + logSecurityEvent('INVALID_REFRESH_TOKEN', req, { error: error.message }); + clearAuthCookies(res); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } - } catch (validationError) { - logSecurityEvent('INVALID_INITDATA_SIGNATURE', req, { reason: validationError.message }); - return res.status(401).json({ error: `${validationError.message}. ${OFFICIAL_CLIENT_MESSAGE}` }); } - const userParam = params.get('user'); - - if (!userParam) { - logSecurityEvent('MISSING_INITDATA_USER', req); + if (!tokenPayload) { + logSecurityEvent('AUTH_TOKEN_MISSING', req); return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } - let telegramUser; - try { - telegramUser = JSON.parse(userParam); - } catch (parseError) { - logSecurityEvent('INVALID_INITDATA_USER_JSON', req, { error: parseError.message }); - return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); - } - - req.telegramUser = telegramUser; - - if (!validateTelegramId(telegramUser.id)) { - logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id }); + if (!validateTelegramId(tokenPayload.telegramId)) { + logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: tokenPayload.telegramId }); + clearAuthCookies(res); return res.status(401).json({ error: 'Неверный ID пользователя' }); } - - // Найти или создать пользователя - let user = await User.findOne({ telegramId: telegramUser.id.toString() }); + + let user = await User.findOne({ telegramId: tokenPayload.telegramId.toString() }); if (!user) { - user = new User({ - telegramId: telegramUser.id.toString(), - username: telegramUser.username || telegramUser.first_name, - firstName: telegramUser.first_name, - lastName: telegramUser.last_name, - photoUrl: telegramUser.photo_url - }); - await user.save(); - console.log(`✅ Создан новый пользователь: ${user.username}`); - } else { - user.username = telegramUser.username || telegramUser.first_name; - user.firstName = telegramUser.first_name; - user.lastName = telegramUser.last_name; - if (telegramUser.photo_url) { - user.photoUrl = telegramUser.photo_url; - } - await user.save(); + clearAuthCookies(res); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } - + + if (user.banned) { + clearAuthCookies(res); + return res.status(403).json({ error: 'Пользователь заблокирован' }); + } + await ensureUserSettings(user); await touchUserActivity(user); req.user = user; + req.telegramUser = { id: user.telegramId }; next(); } catch (error) { console.error('❌ Ошибка авторизации:', error); @@ -193,5 +148,7 @@ const requireAdmin = (req, res, next) => { module.exports = { authenticate, requireModerator, - requireAdmin + requireAdmin, + touchUserActivity, + ensureUserSettings }; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 33c85de..ff827ac 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -6,6 +6,9 @@ const config = require('../config'); const { validateTelegramId } = require('../middleware/validator'); const { logSecurityEvent } = require('../middleware/logger'); const { strictAuthLimiter } = require('../middleware/security'); +const { validateAndParseInitData } = require('../utils/telegram'); +const { signAuthTokens, setAuthCookies, clearAuthCookies } = require('../utils/tokens'); +const { touchUserActivity, ensureUserSettings } = require('../middleware/auth'); const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']; @@ -31,6 +34,93 @@ const normalizeUserSettings = (settings = {}) => { const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot'; +router.post('/signin', strictAuthLimiter, async (req, res) => { + try { + const { initData } = req.body || {}; + + if (!initData || typeof initData !== 'string') { + return res.status(400).json({ error: 'initData обязателен' }); + } + + let telegramUser; + + try { + ({ telegramUser } = validateAndParseInitData(initData, req)); + } catch (error) { + return res.status(401).json({ error: error.message }); + } + + if (!validateTelegramId(telegramUser.id)) { + logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id }); + return res.status(400).json({ error: 'Неверный 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, + firstName: telegramUser.first_name, + lastName: telegramUser.last_name, + photoUrl: telegramUser.photo_url + }); + await user.save(); + } else { + user.username = telegramUser.username || telegramUser.first_name; + user.firstName = telegramUser.first_name; + user.lastName = telegramUser.last_name; + if (telegramUser.photo_url) { + user.photoUrl = telegramUser.photo_url; + } + await user.save(); + } + + if (user.banned) { + return res.status(403).json({ error: 'Пользователь заблокирован' }); + } + + await ensureUserSettings(user); + await touchUserActivity(user); + + const tokens = signAuthTokens(user); + setAuthCookies(res, tokens); + + 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, + settings, + banned: populatedUser.banned + } + }); + } catch (error) { + console.error('Ошибка signin:', error); + res.status(500).json({ error: 'Ошибка авторизации' }); + } +}); + +router.post('/logout', (req, res) => { + clearAuthCookies(res); + res.json({ success: true }); +}); + // Проверка подписи Telegram OAuth (Login Widget) function validateTelegramOAuth(authData, botToken) { if (!authData || !authData.hash) { diff --git a/backend/server.js b/backend/server.js index e7fd724..fa88590 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,7 @@ const cors = require('cors'); const dotenv = require('dotenv'); const path = require('path'); const http = require('http'); +const cookieParser = require('cookie-parser'); // Загрузить переменные окружения ДО импорта config dotenv.config({ path: path.join(__dirname, '.env') }); @@ -55,6 +56,7 @@ app.use(cors(corsOptions)); // Body parsing с ограничениями app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(cookieParser()); // Security middleware app.use(sanitizeMongo); // Защита от NoSQL injection diff --git a/backend/utils/telegram.js b/backend/utils/telegram.js new file mode 100644 index 0000000..c2dc7fd --- /dev/null +++ b/backend/utils/telegram.js @@ -0,0 +1,67 @@ +const crypto = require('crypto'); +const config = require('../config'); + +const MAX_AUTH_AGE_SECONDS = 5 * 60; + +function validateAndParseInitData(initData, req) { + if (!config.telegramBotToken) { + throw new Error('TELEGRAM_BOT_TOKEN не настроен'); + } + + const params = new URLSearchParams(initData); + const hash = params.get('hash'); + const authDate = Number(params.get('auth_date')); + + if (!hash) { + throw new Error('Отсутствует hash в initData'); + } + + if (!authDate) { + throw new Error('Отсутствует auth_date в initData'); + } + + const dataCheck = []; + for (const [key, value] of params.entries()) { + if (key === 'hash') continue; + dataCheck.push(`${key}=${value}`); + } + + dataCheck.sort((a, b) => a.localeCompare(b)); + const dataCheckString = dataCheck.join('\n'); + + const secretKey = crypto.createHmac('sha256', 'WebAppData').update(config.telegramBotToken).digest(); + const calculatedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); + + if (calculatedHash !== hash) { + throw new Error('Неверная подпись initData'); + } + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - authDate) > MAX_AUTH_AGE_SECONDS) { + throw new Error('Данные авторизации устарели'); + } + + const userParam = params.get('user'); + + if (!userParam) { + throw new Error('Отсутствует пользователь в initData'); + } + + let telegramUser; + try { + telegramUser = JSON.parse(userParam); + } catch (error) { + throw new Error('Некорректный формат user в initData'); + } + + if (!telegramUser || !telegramUser.id) { + throw new Error('Отсутствует ID пользователя в initData'); + } + + return { params, telegramUser }; +} + +module.exports = { + validateAndParseInitData +}; + diff --git a/backend/utils/tokens.js b/backend/utils/tokens.js new file mode 100644 index 0000000..9a0e656 --- /dev/null +++ b/backend/utils/tokens.js @@ -0,0 +1,67 @@ +const jwt = require('jsonwebtoken'); +const config = require('../config'); + +const ACCESS_COOKIE = config.jwt.accessCookieName; +const REFRESH_COOKIE = config.jwt.refreshCookieName; + +const buildPayload = (user) => ({ + userId: user._id.toString(), + telegramId: user.telegramId, + role: user.role +}); + +const signAccessToken = (user) => + jwt.sign(buildPayload(user), config.jwt.accessSecret, { + expiresIn: `${config.jwt.accessExpiresIn}s` + }); + +const signRefreshToken = (user) => + jwt.sign(buildPayload(user), config.jwt.refreshSecret, { + expiresIn: `${config.jwt.refreshExpiresIn}s` + }); + +const signAuthTokens = (user) => ({ + accessToken: signAccessToken(user), + refreshToken: signRefreshToken(user) +}); + +const getCookieBaseOptions = () => ({ + httpOnly: true, + secure: config.isProduction(), + sameSite: config.isProduction() ? 'lax' : 'lax', + path: '/' +}); + +const setAuthCookies = (res, tokens) => { + const base = getCookieBaseOptions(); + + res.cookie(ACCESS_COOKIE, tokens.accessToken, { + ...base, + maxAge: config.jwt.accessExpiresIn * 1000 + }); + + res.cookie(REFRESH_COOKIE, tokens.refreshToken, { + ...base, + maxAge: config.jwt.refreshExpiresIn * 1000 + }); +}; + +const clearAuthCookies = (res) => { + const base = getCookieBaseOptions(); + res.clearCookie(ACCESS_COOKIE, base); + res.clearCookie(REFRESH_COOKIE, base); +}; + +const verifyAccessToken = (token) => jwt.verify(token, config.jwt.accessSecret); +const verifyRefreshToken = (token) => jwt.verify(token, config.jwt.refreshSecret); + +module.exports = { + ACCESS_COOKIE, + REFRESH_COOKIE, + signAuthTokens, + setAuthCookies, + clearAuthCookies, + verifyAccessToken, + verifyRefreshToken +}; + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7daf68d..3543b13 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' import { initTelegramApp } from './utils/telegram' -import { verifyAuth, verifySession } from './utils/api' +import { signInWithTelegram, verifyAuth } from './utils/api' import { initTheme } from './utils/theme' import Layout from './components/Layout' import Feed from './pages/Feed' @@ -17,120 +17,68 @@ function AppContent() { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [showLogin, setShowLogin] = useState(false) const navigate = useNavigate() const startParamProcessed = useRef(false) // Флаг для обработки startParam только один раз const initAppCalled = useRef(false) // Флаг чтобы initApp вызывался только один раз useEffect(() => { - // Инициализировать тему только один раз initTheme() - - // Инициализировать приложение только если еще не было вызвано + if (!initAppCalled.current) { initAppCalled.current = true initApp() } }, []) + const waitForInitData = async () => { + const start = Date.now() + const timeout = 5000 + + while (Date.now() - start < timeout) { + const tg = window.Telegram?.WebApp + if (tg?.initData && tg.initData.length > 0) { + return tg + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw new Error('Telegram не передал initData. Откройте приложение в официальном клиенте.') + } + const initApp = async () => { try { - // Инициализация Telegram Web App initTelegramApp() - - // Проверить наличие Telegram Web App API - const tg = window.Telegram?.WebApp - - // Дать время на полную инициализацию Telegram Web App - await new Promise(resolve => setTimeout(resolve, 300)) - - // Проверить наличие initData (главный индикатор что мы в Telegram) - const initData = tg?.initData || '' - - if (initData) { - // Есть initData - пробуем авторизоваться через API - try { - const userData = await verifyAuth() - - // Сохранить сессию для будущих загрузок - localStorage.setItem('nakama_user', JSON.stringify(userData)) - localStorage.setItem('nakama_auth_type', 'telegram') - - // КРИТИЧНО: Сначала установить loading в false, потом user - // Это предотвращает бесконечную загрузку - setLoading(false) - setUser(userData) - - // Обработать параметр start из Telegram (только один раз) - if (!startParamProcessed.current && tg?.startParam?.startsWith('post_')) { - startParamProcessed.current = true // Пометить как обработанный - const postId = tg.startParam.replace('post_', '') - // Использовать navigate вместо window.location.href (не вызывает перезагрузку) - // Задержка чтобы компонент успел отрендериться с user - setTimeout(() => { - navigate(`/feed?post=${postId}`, { replace: true }) - }, 200) - } - return - } catch (authError) { - console.error('Ошибка авторизации через API:', authError) - // Если авторизация не удалась, проверяем сохраненную сессию - const savedUser = localStorage.getItem('nakama_user') - if (savedUser) { - try { - const userData = JSON.parse(savedUser) - // Проверить что сессия еще актуальна (можно добавить проверку времени) - setUser(userData) - setLoading(false) - return - } catch (e) { - console.error('Ошибка восстановления сессии:', e) - localStorage.removeItem('nakama_user') - localStorage.removeItem('nakama_auth_type') - } - } - // Если нет сохраненной сессии, показываем Login Widget - setShowLogin(true) - setLoading(false) - return - } + + const tg = await waitForInitData() + + tg.disableVerticalSwipes?.() + tg.ready?.() + tg.expand?.() + + const userData = await signInWithTelegram(tg.initData) + setUser(userData) + setError(null) + + if (!startParamProcessed.current && tg?.startParam?.startsWith('post_')) { + startParamProcessed.current = true + const postId = tg.startParam.replace('post_', '') + setTimeout(() => { + navigate(`/feed?post=${postId}`, { replace: true }) + }, 200) } - - // Если нет initData, проверяем сохраненную сессию OAuth - const savedUser = localStorage.getItem('nakama_user') - const authType = localStorage.getItem('nakama_auth_type') - - if (savedUser && authType === 'oauth') { - try { - const userData = JSON.parse(savedUser) - - // Проверить сессию на сервере (обновить данные пользователя) - try { - const freshUserData = await verifySession(userData.telegramId) - // Обновить сохраненную сессию - localStorage.setItem('nakama_user', JSON.stringify(freshUserData)) - setUser(freshUserData) - } catch (sessionError) { - console.error('Ошибка проверки сессии:', sessionError) - // Если проверка не удалась, использовать сохраненные данные - setUser(userData) - } - - setLoading(false) - return - } catch (e) { - console.error('Ошибка восстановления сессии:', e) - localStorage.removeItem('nakama_user') - localStorage.removeItem('nakama_auth_type') - } - } - - // Если нет сохраненной сессии и нет initData, показываем Login Widget - setShowLogin(true) - setLoading(false) } catch (err) { console.error('Ошибка инициализации:', err) - setError(err.message) + + try { + // Попытаться восстановить сессию по токенам + const userData = await verifyAuth() + setUser(userData) + setError(null) + } catch (verifyError) { + console.error('Не удалось восстановить сессию:', verifyError) + setError(err?.response?.data?.error || err.message || 'Ошибка авторизации') + } + } finally { setLoading(false) } } @@ -151,46 +99,6 @@ function AppContent() { ) } - // Показать Login Widget если нет авторизации - if (showLogin) { - return ( -
- Для доступа к NakamaHost откройте бота в официальном приложении Telegram. - Если вы уже используете официальный клиент и видите это сообщение, - пожалуйста сообщите об ошибке в - - @NakamaReportbot - . -
- -