Update files
This commit is contained in:
parent
723b6824ea
commit
d6fcfc5c17
|
|
@ -14,6 +14,14 @@ module.exports = {
|
||||||
|
|
||||||
// JWT
|
// JWT
|
||||||
jwtSecret: process.env.JWT_SECRET || 'nakama_secret_key_change_in_production',
|
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
|
// Telegram
|
||||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
|
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
const crypto = require('crypto');
|
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const { validateTelegramId } = require('./validator');
|
const { validateTelegramId } = require('./validator');
|
||||||
const { logSecurityEvent } = require('./logger');
|
const { logSecurityEvent } = require('./logger');
|
||||||
const config = require('../config');
|
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 OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot';
|
||||||
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
|
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
|
||||||
const MAX_AUTH_AGE_SECONDS = 5 * 60; // 5 минут
|
|
||||||
|
|
||||||
const touchUserActivity = async (user) => {
|
const touchUserActivity = async (user) => {
|
||||||
if (!user) return;
|
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 для проверки авторизации
|
// Middleware для проверки авторизации
|
||||||
const authenticate = async (req, res, next) => {
|
const authenticate = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const initData = req.headers['x-telegram-init-data'];
|
const accessToken = req.cookies[ACCESS_COOKIE];
|
||||||
|
const refreshToken = req.cookies[REFRESH_COOKIE];
|
||||||
|
|
||||||
if (!initData) {
|
let tokenPayload = null;
|
||||||
logSecurityEvent('MISSING_INITDATA', req);
|
|
||||||
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.telegramBotToken) {
|
if (accessToken) {
|
||||||
logSecurityEvent('MISSING_BOT_TOKEN', req);
|
|
||||||
if (config.isProduction()) {
|
|
||||||
return res.status(500).json({ error: 'Сервер некорректно настроен для авторизации через Telegram' });
|
|
||||||
}
|
|
||||||
console.warn('⚠️ TELEGRAM_BOT_TOKEN не установлен, пропускаем проверку подписи (dev режим)');
|
|
||||||
}
|
|
||||||
|
|
||||||
let params;
|
|
||||||
try {
|
try {
|
||||||
if (config.telegramBotToken) {
|
tokenPayload = verifyAccessToken(accessToken);
|
||||||
params = validateTelegramWebAppData(initData, config.telegramBotToken);
|
} catch (error) {
|
||||||
} else {
|
if (error.name !== 'TokenExpiredError') {
|
||||||
params = new URLSearchParams(initData);
|
logSecurityEvent('INVALID_ACCESS_TOKEN', req, { error: error.message });
|
||||||
}
|
clearAuthCookies(res);
|
||||||
} 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);
|
|
||||||
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let telegramUser;
|
if (!tokenPayload && refreshToken) {
|
||||||
try {
|
try {
|
||||||
telegramUser = JSON.parse(userParam);
|
const refreshPayload = verifyRefreshToken(refreshToken);
|
||||||
} catch (parseError) {
|
const userForRefresh = await User.findById(refreshPayload.userId);
|
||||||
logSecurityEvent('INVALID_INITDATA_USER_JSON', req, { error: parseError.message });
|
|
||||||
|
if (!userForRefresh) {
|
||||||
|
clearAuthCookies(res);
|
||||||
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.telegramUser = telegramUser;
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!validateTelegramId(telegramUser.id)) {
|
if (!tokenPayload) {
|
||||||
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id });
|
logSecurityEvent('AUTH_TOKEN_MISSING', req);
|
||||||
|
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateTelegramId(tokenPayload.telegramId)) {
|
||||||
|
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: tokenPayload.telegramId });
|
||||||
|
clearAuthCookies(res);
|
||||||
return res.status(401).json({ error: 'Неверный ID пользователя' });
|
return res.status(401).json({ error: 'Неверный ID пользователя' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Найти или создать пользователя
|
let user = await User.findOne({ telegramId: tokenPayload.telegramId.toString() });
|
||||||
let user = await User.findOne({ telegramId: telegramUser.id.toString() });
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = new User({
|
clearAuthCookies(res);
|
||||||
telegramId: telegramUser.id.toString(),
|
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
||||||
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();
|
|
||||||
|
if (user.banned) {
|
||||||
|
clearAuthCookies(res);
|
||||||
|
return res.status(403).json({ error: 'Пользователь заблокирован' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureUserSettings(user);
|
await ensureUserSettings(user);
|
||||||
await touchUserActivity(user);
|
await touchUserActivity(user);
|
||||||
req.user = user;
|
req.user = user;
|
||||||
|
req.telegramUser = { id: user.telegramId };
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка авторизации:', error);
|
console.error('❌ Ошибка авторизации:', error);
|
||||||
|
|
@ -193,5 +148,7 @@ const requireAdmin = (req, res, next) => {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
authenticate,
|
authenticate,
|
||||||
requireModerator,
|
requireModerator,
|
||||||
requireAdmin
|
requireAdmin,
|
||||||
|
touchUserActivity,
|
||||||
|
ensureUserSettings
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ const config = require('../config');
|
||||||
const { validateTelegramId } = require('../middleware/validator');
|
const { validateTelegramId } = require('../middleware/validator');
|
||||||
const { logSecurityEvent } = require('../middleware/logger');
|
const { logSecurityEvent } = require('../middleware/logger');
|
||||||
const { strictAuthLimiter } = require('../middleware/security');
|
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'];
|
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
|
||||||
|
|
||||||
|
|
@ -31,6 +34,93 @@ const normalizeUserSettings = (settings = {}) => {
|
||||||
|
|
||||||
const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot';
|
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)
|
// Проверка подписи Telegram OAuth (Login Widget)
|
||||||
function validateTelegramOAuth(authData, botToken) {
|
function validateTelegramOAuth(authData, botToken) {
|
||||||
if (!authData || !authData.hash) {
|
if (!authData || !authData.hash) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const cors = require('cors');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
|
||||||
// Загрузить переменные окружения ДО импорта config
|
// Загрузить переменные окружения ДО импорта config
|
||||||
dotenv.config({ path: path.join(__dirname, '.env') });
|
dotenv.config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
@ -55,6 +56,7 @@ app.use(cors(corsOptions));
|
||||||
// Body parsing с ограничениями
|
// Body parsing с ограничениями
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Security middleware
|
// Security middleware
|
||||||
app.use(sanitizeMongo); // Защита от NoSQL injection
|
app.use(sanitizeMongo); // Защита от NoSQL injection
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { initTelegramApp } from './utils/telegram'
|
import { initTelegramApp } from './utils/telegram'
|
||||||
import { verifyAuth, verifySession } from './utils/api'
|
import { signInWithTelegram, verifyAuth } from './utils/api'
|
||||||
import { initTheme } from './utils/theme'
|
import { initTheme } from './utils/theme'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Feed from './pages/Feed'
|
import Feed from './pages/Feed'
|
||||||
|
|
@ -17,120 +17,68 @@ function AppContent() {
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showLogin, setShowLogin] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const startParamProcessed = useRef(false) // Флаг для обработки startParam только один раз
|
const startParamProcessed = useRef(false) // Флаг для обработки startParam только один раз
|
||||||
const initAppCalled = useRef(false) // Флаг чтобы initApp вызывался только один раз
|
const initAppCalled = useRef(false) // Флаг чтобы initApp вызывался только один раз
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Инициализировать тему только один раз
|
|
||||||
initTheme()
|
initTheme()
|
||||||
|
|
||||||
// Инициализировать приложение только если еще не было вызвано
|
|
||||||
if (!initAppCalled.current) {
|
if (!initAppCalled.current) {
|
||||||
initAppCalled.current = true
|
initAppCalled.current = true
|
||||||
initApp()
|
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 () => {
|
const initApp = async () => {
|
||||||
try {
|
try {
|
||||||
// Инициализация Telegram Web App
|
|
||||||
initTelegramApp()
|
initTelegramApp()
|
||||||
|
|
||||||
// Проверить наличие Telegram Web App API
|
const tg = await waitForInitData()
|
||||||
const tg = window.Telegram?.WebApp
|
|
||||||
|
|
||||||
// Дать время на полную инициализацию Telegram Web App
|
tg.disableVerticalSwipes?.()
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
tg.ready?.()
|
||||||
|
tg.expand?.()
|
||||||
|
|
||||||
// Проверить наличие initData (главный индикатор что мы в Telegram)
|
const userData = await signInWithTelegram(tg.initData)
|
||||||
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)
|
setUser(userData)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
// Обработать параметр start из Telegram (только один раз)
|
|
||||||
if (!startParamProcessed.current && tg?.startParam?.startsWith('post_')) {
|
if (!startParamProcessed.current && tg?.startParam?.startsWith('post_')) {
|
||||||
startParamProcessed.current = true // Пометить как обработанный
|
startParamProcessed.current = true
|
||||||
const postId = tg.startParam.replace('post_', '')
|
const postId = tg.startParam.replace('post_', '')
|
||||||
// Использовать navigate вместо window.location.href (не вызывает перезагрузку)
|
|
||||||
// Задержка чтобы компонент успел отрендериться с user
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate(`/feed?post=${postId}`, { replace: true })
|
navigate(`/feed?post=${postId}`, { replace: true })
|
||||||
}, 200)
|
}, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если нет 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) {
|
} catch (err) {
|
||||||
console.error('Ошибка инициализации:', 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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,46 +99,6 @@ function AppContent() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Показать Login Widget если нет авторизации
|
|
||||||
if (showLogin) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '100vh',
|
|
||||||
padding: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '16px'
|
|
||||||
}}>
|
|
||||||
<h2 style={{ color: 'var(--text-primary)', margin: 0 }}>Используйте официальный клиент Telegram</h2>
|
|
||||||
<p style={{ color: 'var(--text-secondary)', margin: 0, maxWidth: '360px', lineHeight: 1.5 }}>
|
|
||||||
Для доступа к NakamaHost откройте бота в официальном приложении Telegram.
|
|
||||||
Если вы уже используете официальный клиент и видите это сообщение,
|
|
||||||
пожалуйста сообщите об ошибке в
|
|
||||||
<a href="https://t.me/NakamaReportbot" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>
|
|
||||||
@NakamaReportbot
|
|
||||||
</a>.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
style={{
|
|
||||||
padding: '12px 24px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: 'none',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
fontSize: '16px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Перезагрузить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -209,10 +117,7 @@ function AppContent() {
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => window.location.reload()}
|
||||||
setError(null)
|
|
||||||
setShowLogin(true)
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { getTelegramInitData } from './telegram'
|
|
||||||
|
|
||||||
// API URL из переменных окружения
|
// API URL из переменных окружения
|
||||||
const API_URL = import.meta.env.VITE_API_URL || (
|
const API_URL = import.meta.env.VITE_API_URL || (
|
||||||
|
|
@ -11,48 +10,20 @@ const API_URL = import.meta.env.VITE_API_URL || (
|
||||||
// Создать инстанс axios с настройками
|
// Создать инстанс axios с настройками
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Добавить interceptor для добавления Telegram Init Data или сохраненной сессии
|
|
||||||
api.interceptors.request.use((config) => {
|
|
||||||
const initData = getTelegramInitData()
|
|
||||||
|
|
||||||
// Отправляем initData если есть (для Telegram Mini App)
|
|
||||||
if (initData) {
|
|
||||||
config.headers['x-telegram-init-data'] = initData
|
|
||||||
} else {
|
|
||||||
// Если нет initData, но есть сохраненная сессия OAuth - отправляем telegramId
|
|
||||||
const savedUser = localStorage.getItem('nakama_user')
|
|
||||||
const authType = localStorage.getItem('nakama_auth_type')
|
|
||||||
|
|
||||||
if (savedUser && authType === 'oauth') {
|
|
||||||
try {
|
|
||||||
const userData = JSON.parse(savedUser)
|
|
||||||
if (userData.telegramId) {
|
|
||||||
// Отправляем telegramId для авторизации по сохраненной сессии
|
|
||||||
config.headers['x-telegram-user-id'] = userData.telegramId
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Игнорируем ошибки парсинга
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auth API
|
// Auth API
|
||||||
export const verifyAuth = async () => {
|
export const signInWithTelegram = async (initData) => {
|
||||||
const response = await api.post('/auth/verify')
|
const response = await api.post('/auth/signin', { initData })
|
||||||
return response.data.user
|
return response.data.user
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка сохраненной сессии (для OAuth пользователей)
|
export const verifyAuth = async () => {
|
||||||
export const verifySession = async (telegramId) => {
|
const response = await api.post('/auth/verify')
|
||||||
const response = await api.post('/auth/session', { telegramId })
|
|
||||||
return response.data.user
|
return response.data.user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
signInWithTelegram,
|
||||||
verifyAuth,
|
verifyAuth,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
banUser,
|
banUser,
|
||||||
|
|
@ -129,22 +130,36 @@ export default function App() {
|
||||||
const app = await waitForInitData();
|
const app = await waitForInitData();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const initData = app.initData || '';
|
const initData = app.initData;
|
||||||
|
|
||||||
if (!initData) {
|
if (!initData) {
|
||||||
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
|
throw new Error('Telegram не передал initData');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await verifyAuth();
|
const userData = await signInWithTelegram(initData);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setUser(response.data.user);
|
setUser(userData);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.error('Ошибка инициализации модератора:', err);
|
console.error('Ошибка инициализации модератора:', err);
|
||||||
const serverMessage = err?.response?.data?.error || err?.message;
|
try {
|
||||||
setError(serverMessage || 'Нет доступа. Убедитесь, что вы добавлены как администратор.');
|
const userData = await verifyAuth();
|
||||||
|
if (!cancelled) {
|
||||||
|
setUser(userData);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message =
|
||||||
|
err?.response?.data?.error ||
|
||||||
|
verifyError?.response?.data?.error ||
|
||||||
|
err?.message ||
|
||||||
|
'Нет доступа. Убедитесь, что вы добавлены как администратор.';
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,13 @@ const API_URL =
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
withCredentials: false
|
withCredentials: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const getInitData = () => window.Telegram?.WebApp?.initData || null
|
export const signInWithTelegram = (initData) =>
|
||||||
|
api.post('/auth/signin', { initData }).then((res) => res.data.user)
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
export const verifyAuth = () => api.post('/mod-app/auth/verify').then((res) => res.data.user)
|
||||||
const initData = getInitData()
|
|
||||||
|
|
||||||
if (initData) {
|
|
||||||
config.headers['x-telegram-init-data'] = initData
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
export const verifyAuth = () => api.post('/mod-app/auth/verify')
|
|
||||||
|
|
||||||
export const fetchUsers = (params = {}) =>
|
export const fetchUsers = (params = {}) =>
|
||||||
api.get('/mod-app/users', { params }).then((res) => res.data)
|
api.get('/mod-app/users', { params }).then((res) => res.data)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@
|
||||||
"express-mongo-sanitize": "^2.2.0",
|
"express-mongo-sanitize": "^2.2.0",
|
||||||
"xss-clean": "^0.1.4",
|
"xss-clean": "^0.1.4",
|
||||||
"hpp": "^0.2.3",
|
"hpp": "^0.2.3",
|
||||||
"validator": "^13.11.0"
|
"validator": "^13.11.0",
|
||||||
|
"cookie-parser": "^1.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue