nakama/backend/routes/auth.js

723 lines
26 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,
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: 'Ошибка сервера' });
}
});
// 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',
`
<h1>Добро пожаловать в Nakama!</h1>
<p>Нажмите на ссылку ниже, чтобы войти:</p>
<p><a href="${magicLink}" style="display: inline-block; padding: 12px 24px; background: #007AFF; color: white; text-decoration: none; border-radius: 8px;">Войти в Nakama</a></p>
<p>Ссылка действительна 15 минут.</p>
<p style="color: #666; font-size: 14px;">Если вы не запрашивали эту ссылку, просто проигнорируйте это письмо.</p>
`,
`Войдите в 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 } = 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 символов' });
}
// Найти пользователя с этим токеном
const user = await User.findOne({
magicLinkToken: token,
magicLinkExpires: { $gt: new Date() }
});
if (!user) {
return res.status(401).json({ error: 'Ссылка недействительна или устарела' });
}
// Хешируем пароль
const bcrypt = require('bcrypt');
const passwordHash = await bcrypt.hash(password, 10);
// Обновляем пользователя
user.passwordHash = passwordHash;
user.emailVerified = true;
user.magicLinkToken = undefined;
user.magicLinkExpires = undefined;
user.lastActiveAt = new Date();
if (username && !user.username) {
user.username = username;
}
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('bcrypt');
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('bcrypt');
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;