nakama/backend/routes/auth.js

371 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
tickets: populatedUser.tickets || 0,
settings,
banned: populatedUser.banned
}
});
};
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 {
const { user: telegramUser, auth_date, hash } = req.body;
if (!telegramUser || !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);
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: 'Ошибка сервера' });
}
});
// Проверка сохраненной сессии по 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: 'Ошибка сервера' });
}
});
module.exports = router;