добавление блокировки по железу
This commit is contained in:
parent
c97efd7a5a
commit
2317ecafca
361
backend/controllers/deviceSecurityController.js
Normal file
361
backend/controllers/deviceSecurityController.js
Normal file
@ -0,0 +1,361 @@
|
||||
const BlockedDevice = require('../models/BlockedDevice');
|
||||
const User = require('../models/User');
|
||||
const { blockUserDevice, unblockDevice, getBlockedDevices } = require('../middleware/deviceBlockMiddleware');
|
||||
|
||||
/**
|
||||
* Контроллер для управления блокировками устройств
|
||||
*/
|
||||
|
||||
// @desc Проверка блокировки устройства
|
||||
// @route POST /api/security/check-device
|
||||
// @access Public
|
||||
const checkDeviceBlockStatus = async (req, res) => {
|
||||
try {
|
||||
const { fingerprint, deviceInfo } = req.body;
|
||||
const deviceFingerprint = fingerprint || req.deviceFingerprint;
|
||||
|
||||
if (!deviceFingerprint) {
|
||||
return res.status(400).json({
|
||||
message: 'Device fingerprint требуется для проверки безопасности',
|
||||
code: 'DEVICE_FINGERPRINT_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[SECURITY] Проверка блокировки устройства:', deviceFingerprint.substring(0, 16) + '...');
|
||||
|
||||
// Проверяем, заблокировано ли устройство
|
||||
const blockedDevice = await BlockedDevice.isDeviceBlocked(deviceFingerprint);
|
||||
|
||||
if (blockedDevice) {
|
||||
console.log('[SECURITY] Устройство заблокировано:', blockedDevice._id);
|
||||
|
||||
// Записываем попытку доступа
|
||||
await blockedDevice.addBypassAttempt({
|
||||
type: 'api_request',
|
||||
ipAddress: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
blocked: true,
|
||||
message: 'Доступ с данного устройства заблокирован',
|
||||
reason: blockedDevice.reason,
|
||||
blockedAt: blockedDevice.blockedAt,
|
||||
code: 'DEVICE_BLOCKED'
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем информацию об устройстве
|
||||
if (deviceInfo) {
|
||||
// Можно сохранить информацию об активном устройстве для мониторинга
|
||||
console.log('[SECURITY] Активное устройство:', {
|
||||
fingerprint: deviceFingerprint.substring(0, 16) + '...',
|
||||
userAgent: deviceInfo.userAgent,
|
||||
platform: deviceInfo.platform
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
blocked: false,
|
||||
message: 'Устройство не заблокировано'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SECURITY] Ошибка при проверке блокировки устройства:', error);
|
||||
res.status(500).json({
|
||||
message: 'Ошибка системы безопасности',
|
||||
code: 'SECURITY_CHECK_ERROR'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Отчет о подозрительной активности
|
||||
// @route POST /api/security/report-suspicious
|
||||
// @access Private
|
||||
const reportSuspiciousActivity = async (req, res) => {
|
||||
try {
|
||||
const { activities, deviceInfo } = req.body;
|
||||
const deviceFingerprint = req.deviceFingerprint;
|
||||
|
||||
if (!deviceFingerprint) {
|
||||
return res.status(400).json({
|
||||
message: 'Device fingerprint требуется для отчета',
|
||||
code: 'DEVICE_FINGERPRINT_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[SECURITY] Получен отчет о подозрительной активности:', {
|
||||
fingerprint: deviceFingerprint.substring(0, 16) + '...',
|
||||
activitiesCount: activities?.length || 0,
|
||||
userId: req.user?._id
|
||||
});
|
||||
|
||||
// Анализируем активность на предмет угроз
|
||||
const riskScore = calculateRiskScore(activities, deviceInfo);
|
||||
|
||||
if (riskScore > 80) {
|
||||
console.warn('[SECURITY] Высокий уровень риска обнаружен:', riskScore);
|
||||
|
||||
// Автоматическая блокировка при высоком риске
|
||||
if (req.user) {
|
||||
await blockUserDevice(
|
||||
req.user._id,
|
||||
req.user._id, // Автоматическая блокировка
|
||||
`Автоматическая блокировка: высокий уровень риска (${riskScore})`,
|
||||
deviceFingerprint
|
||||
);
|
||||
|
||||
console.log('[SECURITY] Устройство автоматически заблокировано из-за высокого риска');
|
||||
|
||||
return res.status(403).json({
|
||||
blocked: true,
|
||||
message: 'Устройство заблокировано из-за подозрительной активности',
|
||||
reason: 'Автоматическая блокировка системы безопасности',
|
||||
code: 'DEVICE_BLOCKED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем отчет для анализа администраторами
|
||||
// Можно создать отдельную модель SuspiciousActivity или логировать
|
||||
console.log('[SECURITY] Отчет о подозрительной активности сохранен');
|
||||
|
||||
res.json({
|
||||
message: 'Отчет получен и обработан',
|
||||
riskScore,
|
||||
action: riskScore > 80 ? 'blocked' : 'monitored'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SECURITY] Ошибка при обработке отчета о подозрительной активности:', error);
|
||||
res.status(500).json({
|
||||
message: 'Ошибка обработки отчета безопасности'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Блокировка устройства (админ)
|
||||
// @route POST /api/admin/block-device
|
||||
// @access Private/Admin
|
||||
const blockDevice = async (req, res) => {
|
||||
try {
|
||||
const { userId, deviceFingerprint, reason } = req.body;
|
||||
|
||||
if (!userId || !deviceFingerprint || !reason) {
|
||||
return res.status(400).json({
|
||||
message: 'ID пользователя, device fingerprint и причина обязательны'
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
message: 'Пользователь не найден'
|
||||
});
|
||||
}
|
||||
|
||||
// Блокируем устройство
|
||||
const blockedDevice = await blockUserDevice(
|
||||
userId,
|
||||
req.user._id,
|
||||
reason,
|
||||
deviceFingerprint
|
||||
);
|
||||
|
||||
console.log('[ADMIN] Устройство заблокировано администратором:', {
|
||||
deviceId: blockedDevice._id,
|
||||
userId,
|
||||
adminId: req.user._id,
|
||||
reason
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Устройство успешно заблокировано',
|
||||
blockedDevice: {
|
||||
id: blockedDevice._id,
|
||||
deviceFingerprint: blockedDevice.deviceFingerprint,
|
||||
reason: blockedDevice.reason,
|
||||
blockedAt: blockedDevice.blockedAt
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ADMIN] Ошибка при блокировке устройства:', error);
|
||||
res.status(500).json({
|
||||
message: 'Ошибка при блокировке устройства',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Разблокировка устройства (админ)
|
||||
// @route POST /api/admin/unblock-device
|
||||
// @access Private/Admin
|
||||
const unblockDeviceAdmin = async (req, res) => {
|
||||
try {
|
||||
const { deviceFingerprint, reason } = req.body;
|
||||
|
||||
if (!deviceFingerprint) {
|
||||
return res.status(400).json({
|
||||
message: 'Device fingerprint обязателен'
|
||||
});
|
||||
}
|
||||
|
||||
// Разблокируем устройство
|
||||
const device = await unblockDevice(
|
||||
deviceFingerprint,
|
||||
req.user._id,
|
||||
reason || 'Разблокировано администратором'
|
||||
);
|
||||
|
||||
console.log('[ADMIN] Устройство разблокировано администратором:', {
|
||||
deviceId: device._id,
|
||||
adminId: req.user._id,
|
||||
reason
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Устройство успешно разблокировано',
|
||||
device: {
|
||||
id: device._id,
|
||||
deviceFingerprint: device.deviceFingerprint,
|
||||
unblockedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ADMIN] Ошибка при разблокировке устройства:', error);
|
||||
res.status(500).json({
|
||||
message: 'Ошибка при разблокировке устройства',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Получение списка заблокированных устройств
|
||||
// @route GET /api/admin/blocked-devices
|
||||
// @access Private/Admin
|
||||
const getBlockedDevicesList = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
sortBy = 'blockedAt',
|
||||
sortOrder = 'desc',
|
||||
isActive = 'true',
|
||||
userId
|
||||
} = req.query;
|
||||
|
||||
const filters = {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
sortBy,
|
||||
sortOrder,
|
||||
isActive: isActive === 'true',
|
||||
userId
|
||||
};
|
||||
|
||||
const result = await getBlockedDevices(filters);
|
||||
|
||||
console.log('[ADMIN] Запрос списка заблокированных устройств:', {
|
||||
adminId: req.user._id,
|
||||
filters,
|
||||
totalItems: result.pagination.totalItems
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.devices,
|
||||
pagination: result.pagination
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ADMIN] Ошибка при получении списка заблокированных устройств:', error);
|
||||
res.status(500).json({
|
||||
message: 'Ошибка при получении списка заблокированных устройств',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Получение детальной информации о заблокированном устройстве
|
||||
// @route GET /api/admin/blocked-devices/:id
|
||||
// @access Private/Admin
|
||||
const getBlockedDeviceDetails = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const device = await BlockedDevice.findById(id)
|
||||
.populate('blockedBy', 'name email')
|
||||
.populate('blockedUserId', 'name email')
|
||||
.populate('unblockRequests.requestedBy', 'name email')
|
||||
.populate('unblockRequests.reviewedBy', 'name email');
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({
|
||||
message: 'Заблокированное устройство не найдено'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[ADMIN] Запрос деталей заблокированного устройства:', {
|
||||
deviceId: id,
|
||||
adminId: req.user._id
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: device
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ADMIN] Ошибка при получении деталей заблокированного устройства:', error);
|
||||
res.status(500).json({
|
||||
message: 'Ошибка при получении информации об устройстве',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Вычисляет уровень риска на основе подозрительной активности
|
||||
*/
|
||||
function calculateRiskScore(activities, deviceInfo) {
|
||||
let score = 0;
|
||||
|
||||
if (!activities || activities.length === 0) return 0;
|
||||
|
||||
activities.forEach(activity => {
|
||||
switch (activity.type) {
|
||||
case 'fingerprint_change':
|
||||
score += 30; // Изменение fingerprint - высокий риск
|
||||
break;
|
||||
case 'multiple_login_attempts':
|
||||
score += 20;
|
||||
break;
|
||||
case 'suspicious_user_agent':
|
||||
score += 15;
|
||||
break;
|
||||
case 'rapid_requests':
|
||||
score += 10;
|
||||
break;
|
||||
default:
|
||||
score += 5;
|
||||
}
|
||||
});
|
||||
|
||||
// Дополнительные факторы риска
|
||||
if (activities.length > 5) score += 10; // Много событий
|
||||
if (deviceInfo?.visitCount === 1) score += 15; // Новое устройство
|
||||
|
||||
return Math.min(score, 100); // Максимум 100
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkDeviceBlockStatus,
|
||||
reportSuspiciousActivity,
|
||||
blockDevice,
|
||||
unblockDeviceAdmin,
|
||||
getBlockedDevicesList,
|
||||
getBlockedDeviceDetails
|
||||
};
|
231
backend/middleware/deviceBlockMiddleware.js
Normal file
231
backend/middleware/deviceBlockMiddleware.js
Normal file
@ -0,0 +1,231 @@
|
||||
const BlockedDevice = require('../models/BlockedDevice');
|
||||
|
||||
/**
|
||||
* Middleware для проверки блокировки устройства
|
||||
* Должен использоваться перед аутентификацией
|
||||
*/
|
||||
const checkDeviceBlock = async (req, res, next) => {
|
||||
try {
|
||||
console.log('[DEVICE_CHECK] Проверка блокировки устройства');
|
||||
|
||||
// Получаем fingerprint устройства из заголовков запроса
|
||||
const deviceFingerprint = req.headers['x-device-fingerprint'];
|
||||
|
||||
if (!deviceFingerprint) {
|
||||
console.log('[DEVICE_CHECK] Device fingerprint не предоставлен');
|
||||
return res.status(400).json({
|
||||
message: 'Требуется идентификация устройства для обеспечения безопасности',
|
||||
code: 'DEVICE_FINGERPRINT_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[DEVICE_CHECK] Проверяем fingerprint:', deviceFingerprint.substring(0, 16) + '...');
|
||||
|
||||
// Проверяем, заблокировано ли устройство
|
||||
const blockedDevice = await BlockedDevice.isDeviceBlocked(deviceFingerprint);
|
||||
|
||||
if (blockedDevice) {
|
||||
console.log('[DEVICE_CHECK] Устройство заблокировано:', {
|
||||
deviceId: blockedDevice._id,
|
||||
blockedAt: blockedDevice.blockedAt,
|
||||
reason: blockedDevice.reason
|
||||
});
|
||||
|
||||
// Записываем попытку обхода блокировки
|
||||
await blockedDevice.addBypassAttempt({
|
||||
type: req.route?.path?.includes('/login') ? 'login' :
|
||||
req.route?.path?.includes('/register') ? 'register' : 'api_request',
|
||||
ipAddress: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
// Возвращаем ошибку блокировки
|
||||
return res.status(403).json({
|
||||
blocked: true,
|
||||
message: 'Доступ с данного устройства заблокирован',
|
||||
reason: blockedDevice.reason,
|
||||
blockedAt: blockedDevice.blockedAt,
|
||||
code: 'DEVICE_BLOCKED'
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем похожие заблокированные устройства
|
||||
const similarDevices = await BlockedDevice.findSimilarDevices(deviceFingerprint, 0.85);
|
||||
|
||||
if (similarDevices.length > 0) {
|
||||
console.log('[DEVICE_CHECK] Найдены похожие заблокированные устройства:', similarDevices.length);
|
||||
|
||||
// Повышаем уровень безопасности для похожих устройств
|
||||
req.securityRisk = 'HIGH';
|
||||
req.similarBlockedDevices = similarDevices;
|
||||
|
||||
// Можно добавить дополнительные проверки для подозрительных устройств
|
||||
// Например, CAPTCHA или дополнительную верификацию
|
||||
}
|
||||
|
||||
// Сохраняем fingerprint в запросе для дальнейшего использования
|
||||
req.deviceFingerprint = deviceFingerprint;
|
||||
req.deviceInfo = {
|
||||
userAgent: req.headers['user-agent'],
|
||||
ipAddress: req.ip || req.connection.remoteAddress,
|
||||
screenResolution: req.headers['x-screen-resolution'],
|
||||
platform: req.headers['x-platform'],
|
||||
language: req.headers['x-language'],
|
||||
timezone: req.headers['x-timezone']
|
||||
};
|
||||
|
||||
console.log('[DEVICE_CHECK] Устройство не заблокировано, продолжаем');
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DEVICE_CHECK] Ошибка при проверке блокировки устройства:', error);
|
||||
|
||||
// В случае ошибки системы безопасности, блокируем доступ
|
||||
return res.status(500).json({
|
||||
message: 'Ошибка системы безопасности. Попробуйте позже.',
|
||||
code: 'SECURITY_CHECK_ERROR'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware для записи информации об устройстве при успешной аутентификации
|
||||
*/
|
||||
const recordDeviceActivity = async (req, res, next) => {
|
||||
try {
|
||||
if (req.deviceFingerprint && req.user) {
|
||||
console.log('[DEVICE_RECORD] Записываем активность устройства для пользователя:', req.user._id);
|
||||
|
||||
// Можно сохранить информацию об устройстве пользователя для анализа
|
||||
// Например, в отдельной коллекции UserDevices
|
||||
|
||||
// Пока просто логируем
|
||||
console.log('[DEVICE_RECORD] Device fingerprint:', req.deviceFingerprint.substring(0, 16) + '...');
|
||||
console.log('[DEVICE_RECORD] Device info:', req.deviceInfo);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('[DEVICE_RECORD] Ошибка при записи активности устройства:', error);
|
||||
// Не блокируем запрос из-за ошибки записи
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Функция для блокировки устройства пользователя
|
||||
*/
|
||||
const blockUserDevice = async (userId, adminId, reason, deviceFingerprint = null) => {
|
||||
try {
|
||||
console.log('[BLOCK_DEVICE] Блокировка устройства пользователя:', userId);
|
||||
|
||||
if (!deviceFingerprint) {
|
||||
// Если fingerprint не предоставлен, нужно найти его в активных сессиях
|
||||
// Это более сложная задача, требующая отслеживания активных сессий
|
||||
throw new Error('Device fingerprint не предоставлен для блокировки');
|
||||
}
|
||||
|
||||
const deviceInfo = {
|
||||
// Получаем дополнительную информацию об устройстве из базы данных или сессии
|
||||
// Это может быть реализовано через отслеживание активных подключений
|
||||
};
|
||||
|
||||
const blockedDevice = await BlockedDevice.blockDevice({
|
||||
deviceFingerprint,
|
||||
blockedBy: adminId,
|
||||
blockedUserId: userId,
|
||||
reason,
|
||||
deviceInfo
|
||||
});
|
||||
|
||||
console.log('[BLOCK_DEVICE] Устройство заблокировано:', blockedDevice._id);
|
||||
return blockedDevice;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[BLOCK_DEVICE] Ошибка при блокировке устройства:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Функция для разблокировки устройства
|
||||
*/
|
||||
const unblockDevice = async (deviceFingerprint, adminId, reason) => {
|
||||
try {
|
||||
console.log('[UNBLOCK_DEVICE] Разблокировка устройства:', deviceFingerprint.substring(0, 16) + '...');
|
||||
|
||||
const blockedDevice = await BlockedDevice.findOne({
|
||||
deviceFingerprint,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!blockedDevice) {
|
||||
throw new Error('Заблокированное устройство не найдено');
|
||||
}
|
||||
|
||||
await blockedDevice.unblock(adminId, reason);
|
||||
|
||||
console.log('[UNBLOCK_DEVICE] Устройство разблокировано');
|
||||
return blockedDevice;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[UNBLOCK_DEVICE] Ошибка при разблокировке устройства:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Функция для получения информации о заблокированных устройствах
|
||||
*/
|
||||
const getBlockedDevices = async (filters = {}) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
sortBy = 'blockedAt',
|
||||
sortOrder = 'desc',
|
||||
isActive = true,
|
||||
userId
|
||||
} = filters;
|
||||
|
||||
const query = { isActive };
|
||||
|
||||
if (userId) {
|
||||
query.blockedUserId = userId;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
const sort = { [sortBy]: sortOrder === 'desc' ? -1 : 1 };
|
||||
|
||||
const devices = await BlockedDevice.find(query)
|
||||
.populate('blockedBy', 'name email')
|
||||
.populate('blockedUserId', 'name email')
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit);
|
||||
|
||||
const total = await BlockedDevice.countDocuments(query);
|
||||
|
||||
return {
|
||||
devices,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
totalItems: total,
|
||||
itemsPerPage: limit
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[GET_BLOCKED_DEVICES] Ошибка при получении заблокированных устройств:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
checkDeviceBlock,
|
||||
recordDeviceActivity,
|
||||
blockUserDevice,
|
||||
unblockDevice,
|
||||
getBlockedDevices
|
||||
};
|
275
backend/models/BlockedDevice.js
Normal file
275
backend/models/BlockedDevice.js
Normal file
@ -0,0 +1,275 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const blockedDeviceSchema = new mongoose.Schema(
|
||||
{
|
||||
deviceFingerprint: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
unique: true
|
||||
},
|
||||
blockedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
blockedUserId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
reason: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 500
|
||||
},
|
||||
blockedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// Дополнительная информация об устройстве для анализа
|
||||
deviceInfo: {
|
||||
userAgent: String,
|
||||
screenResolution: String,
|
||||
platform: String,
|
||||
language: String,
|
||||
timezone: String,
|
||||
ipAddress: String, // Сохраняем IP для дополнительного анализа
|
||||
lastSeen: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
},
|
||||
// Попытки обхода блокировки
|
||||
bypassAttempts: [{
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
attemptType: {
|
||||
type: String,
|
||||
enum: ['login', 'register', 'api_request'],
|
||||
required: true
|
||||
},
|
||||
ipAddress: String,
|
||||
userAgent: String,
|
||||
blocked: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}],
|
||||
// Связанные fingerprints (для обнаружения похожих устройств)
|
||||
relatedFingerprints: [{
|
||||
fingerprint: String,
|
||||
similarity: Number, // Процент схожести
|
||||
detectedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}],
|
||||
unblockRequests: [{
|
||||
requestedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
requestedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
reason: String,
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'approved', 'rejected'],
|
||||
default: 'pending'
|
||||
},
|
||||
reviewedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
reviewedAt: Date,
|
||||
reviewNote: String
|
||||
}]
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Индексы для оптимизации поиска
|
||||
blockedDeviceSchema.index({ deviceFingerprint: 1, isActive: 1 });
|
||||
blockedDeviceSchema.index({ blockedUserId: 1 });
|
||||
blockedDeviceSchema.index({ blockedAt: 1 });
|
||||
blockedDeviceSchema.index({ 'deviceInfo.ipAddress': 1 });
|
||||
|
||||
// Методы модели
|
||||
blockedDeviceSchema.methods.addBypassAttempt = function(attemptData) {
|
||||
this.bypassAttempts.push({
|
||||
attemptType: attemptData.type,
|
||||
ipAddress: attemptData.ipAddress,
|
||||
userAgent: attemptData.userAgent,
|
||||
blocked: true
|
||||
});
|
||||
|
||||
// Ограничиваем количество записей попыток
|
||||
if (this.bypassAttempts.length > 100) {
|
||||
this.bypassAttempts = this.bypassAttempts.slice(-100);
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
blockedDeviceSchema.methods.addRelatedFingerprint = function(fingerprint, similarity) {
|
||||
// Проверяем, не добавлен ли уже этот fingerprint
|
||||
const exists = this.relatedFingerprints.find(rf => rf.fingerprint === fingerprint);
|
||||
if (!exists) {
|
||||
this.relatedFingerprints.push({
|
||||
fingerprint,
|
||||
similarity
|
||||
});
|
||||
|
||||
// Ограничиваем количество связанных fingerprints
|
||||
if (this.relatedFingerprints.length > 50) {
|
||||
this.relatedFingerprints = this.relatedFingerprints.slice(-50);
|
||||
}
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
blockedDeviceSchema.methods.unblock = function(unblockedBy, reason) {
|
||||
this.isActive = false;
|
||||
this.unblockRequests.push({
|
||||
requestedBy: unblockedBy,
|
||||
reason: reason || 'Разблокировано администратором',
|
||||
status: 'approved',
|
||||
reviewedBy: unblockedBy,
|
||||
reviewedAt: new Date(),
|
||||
reviewNote: 'Разблокировано напрямую'
|
||||
});
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Статические методы
|
||||
blockedDeviceSchema.statics.isDeviceBlocked = async function(deviceFingerprint) {
|
||||
const blockedDevice = await this.findOne({
|
||||
deviceFingerprint,
|
||||
isActive: true
|
||||
}).populate('blockedUserId', 'name email');
|
||||
|
||||
return blockedDevice;
|
||||
};
|
||||
|
||||
blockedDeviceSchema.statics.blockDevice = async function(deviceData) {
|
||||
const {
|
||||
deviceFingerprint,
|
||||
blockedBy,
|
||||
blockedUserId,
|
||||
reason,
|
||||
deviceInfo
|
||||
} = deviceData;
|
||||
|
||||
// Проверяем, не заблокировано ли уже устройство
|
||||
const existing = await this.findOne({ deviceFingerprint });
|
||||
|
||||
if (existing) {
|
||||
if (!existing.isActive) {
|
||||
// Реактивируем блокировку
|
||||
existing.isActive = true;
|
||||
existing.reason = reason;
|
||||
existing.blockedBy = blockedBy;
|
||||
existing.blockedAt = new Date();
|
||||
if (deviceInfo) {
|
||||
existing.deviceInfo = { ...existing.deviceInfo, ...deviceInfo };
|
||||
}
|
||||
return await existing.save();
|
||||
} else {
|
||||
// Устройство уже заблокировано
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем новую блокировку
|
||||
const blockedDevice = new this({
|
||||
deviceFingerprint,
|
||||
blockedBy,
|
||||
blockedUserId,
|
||||
reason,
|
||||
deviceInfo
|
||||
});
|
||||
|
||||
return await blockedDevice.save();
|
||||
};
|
||||
|
||||
blockedDeviceSchema.statics.findSimilarDevices = async function(deviceFingerprint, threshold = 0.8) {
|
||||
// Простой алгоритм поиска похожих fingerprints
|
||||
const allDevices = await this.find({ isActive: true });
|
||||
const similar = [];
|
||||
|
||||
for (const device of allDevices) {
|
||||
const similarity = this.calculateSimilarity(deviceFingerprint, device.deviceFingerprint);
|
||||
if (similarity >= threshold) {
|
||||
similar.push({
|
||||
device,
|
||||
similarity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return similar.sort((a, b) => b.similarity - a.similarity);
|
||||
};
|
||||
|
||||
blockedDeviceSchema.statics.calculateSimilarity = function(fp1, fp2) {
|
||||
if (fp1 === fp2) return 1.0;
|
||||
|
||||
// Простой алгоритм сравнения строк
|
||||
const longer = fp1.length > fp2.length ? fp1 : fp2;
|
||||
const shorter = fp1.length > fp2.length ? fp2 : fp1;
|
||||
|
||||
if (longer.length === 0) return 1.0;
|
||||
|
||||
const editDistance = this.levenshteinDistance(longer, shorter);
|
||||
return (longer.length - editDistance) / longer.length;
|
||||
};
|
||||
|
||||
blockedDeviceSchema.statics.levenshteinDistance = function(str1, str2) {
|
||||
const matrix = [];
|
||||
|
||||
for (let i = 0; i <= str2.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
|
||||
for (let j = 0; j <= str1.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= str2.length; i++) {
|
||||
for (let j = 1; j <= str1.length; j++) {
|
||||
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j - 1] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j] + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length][str1.length];
|
||||
};
|
||||
|
||||
// Middleware для обновления lastSeen
|
||||
blockedDeviceSchema.pre('save', function(next) {
|
||||
if (this.deviceInfo) {
|
||||
this.deviceInfo.lastSeen = new Date();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('BlockedDevice', blockedDeviceSchema);
|
@ -9,6 +9,12 @@ const {
|
||||
updateReportStatus,
|
||||
getReportsStats
|
||||
} = require('../controllers/reportController');
|
||||
const {
|
||||
blockDevice,
|
||||
unblockDeviceAdmin,
|
||||
getBlockedDevicesList,
|
||||
getBlockedDeviceDetails
|
||||
} = require('../controllers/deviceSecurityController');
|
||||
|
||||
// Все маршруты защищены middleware для проверки авторизации и прав администратора
|
||||
router.use(protect, adminMiddleware);
|
||||
@ -32,4 +38,10 @@ router.get('/conversations', adminController.getAllConversations);
|
||||
router.get('/conversations/:id', adminController.getConversationById); // Новый маршрут для получения диалога по ID
|
||||
router.get('/conversations/:id/messages', adminController.getConversationMessages);
|
||||
|
||||
// Маршруты для управления блокировками устройств
|
||||
router.get('/blocked-devices', getBlockedDevicesList);
|
||||
router.get('/blocked-devices/:id', getBlockedDeviceDetails);
|
||||
router.post('/block-device', blockDevice);
|
||||
router.post('/unblock-device', unblockDeviceAdmin);
|
||||
|
||||
module.exports = router;
|
@ -11,6 +11,7 @@ const {
|
||||
|
||||
// Импортируем middleware для защиты маршрутов
|
||||
const { protect } = require('../middleware/authMiddleware');
|
||||
const { checkDeviceBlock, recordDeviceActivity } = require('../middleware/deviceBlockMiddleware');
|
||||
|
||||
// --- НАЧАЛО ИЗМЕНЕНИЯ ---
|
||||
// Дополнительная отладка: Проверяем, что protect действительно функция
|
||||
@ -27,15 +28,16 @@ console.log('[DEBUG] authRoutes.js - typeof registerUser:', typeof registerUser)
|
||||
console.log('[DEBUG] authRoutes.js - typeof loginUser:', typeof loginUser);
|
||||
console.log('[DEBUG] authRoutes.js - typeof getMe:', typeof getMe);
|
||||
console.log('[DEBUG] authRoutes.js - typeof protect:', typeof protect);
|
||||
console.log('[DEBUG] authRoutes.js - typeof checkDeviceBlock:', typeof checkDeviceBlock);
|
||||
|
||||
// Регистрация пользователя - публичный доступ
|
||||
router.post('/register', registerUser);
|
||||
// Регистрация пользователя - с проверкой блокировки устройства
|
||||
router.post('/register', checkDeviceBlock, registerUser);
|
||||
|
||||
// Вход пользователя - публичный доступ
|
||||
router.post('/login', loginUser);
|
||||
// Вход пользователя - с проверкой блокировки устройства и записью активности
|
||||
router.post('/login', checkDeviceBlock, loginUser, recordDeviceActivity);
|
||||
|
||||
// Получение профиля - защищенный доступ
|
||||
router.get('/me', protect, getMe);
|
||||
// Получение профиля - защищенный доступ с записью активности устройства
|
||||
router.get('/me', protect, recordDeviceActivity, getMe);
|
||||
|
||||
// Экспортируем маршруты
|
||||
module.exports = router;
|
||||
|
19
backend/routes/securityRoutes.js
Normal file
19
backend/routes/securityRoutes.js
Normal file
@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
checkDeviceBlockStatus,
|
||||
reportSuspiciousActivity
|
||||
} = require('../controllers/deviceSecurityController');
|
||||
const { checkDeviceBlock } = require('../middleware/deviceBlockMiddleware');
|
||||
|
||||
// @route POST /api/security/check-device
|
||||
// @desc Проверка блокировки устройства
|
||||
// @access Public
|
||||
router.post('/check-device', checkDeviceBlockStatus);
|
||||
|
||||
// @route POST /api/security/report-suspicious
|
||||
// @desc Отчет о подозрительной активности
|
||||
// @access Private (требует device fingerprint)
|
||||
router.post('/report-suspicious', checkDeviceBlock, reportSuspiciousActivity);
|
||||
|
||||
module.exports = router;
|
@ -15,6 +15,7 @@ const userRoutes = require('./routes/userRoutes'); // 1. Импортируем
|
||||
const actionRoutes = require('./routes/actionRoutes'); // 1. Импортируем actionRoutes
|
||||
const adminRoutes = require('./routes/adminRoutes'); // Импортируем маршруты админа
|
||||
const reportRoutes = require('./routes/reportRoutes'); // Импортируем маршруты для жалоб
|
||||
const securityRoutes = require('./routes/securityRoutes'); // Импортируем маршруты безопасности
|
||||
const Message = require('./models/Message'); // Импорт модели Message
|
||||
const Conversation = require('./models/Conversation'); // Импорт модели Conversation
|
||||
const initAdminAccount = require('./utils/initAdmin'); // Импорт инициализации админ-аккаунта
|
||||
@ -87,6 +88,7 @@ app.use('/api/actions', actionRoutes);
|
||||
app.use('/api/conversations', conversationRoutes);
|
||||
app.use('/api/reports', reportRoutes); // Подключаем маршруты для жалоб
|
||||
app.use('/api/admin', adminRoutes); // Подключаем маршруты для админ-панели
|
||||
app.use('/api/security', securityRoutes); // Подключаем маршруты для системы безопасности устройств
|
||||
|
||||
// Socket.IO логика
|
||||
let activeUsers = [];
|
||||
|
86
src/auth.js
86
src/auth.js
@ -1,6 +1,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import api from './services/api'; // Наш API сервис
|
||||
import router from './router'; // Vue Router для перенаправлений
|
||||
import deviceSecurityService from './services/deviceSecurity'; // Система безопасности устройств
|
||||
|
||||
// Реактивные переменные для состояния пользователя и токена
|
||||
const user = ref(null); // Данные пользователя (или null, если не вошел)
|
||||
@ -58,6 +59,11 @@ async function login(credentials) {
|
||||
try {
|
||||
console.log('Попытка входа с учетными данными:', { email: credentials.email });
|
||||
|
||||
// Проверяем, не заблокировано ли устройство перед попыткой входа
|
||||
if (deviceSecurityService.isDeviceBlocked()) {
|
||||
throw new Error('Доступ с данного устройства заблокирован');
|
||||
}
|
||||
|
||||
const response = await api.login(credentials);
|
||||
console.log('Ответ сервера при входе:', response);
|
||||
|
||||
@ -79,6 +85,11 @@ async function login(credentials) {
|
||||
// Сохраняем данные пользователя и токен
|
||||
setUserData(userData, userToken);
|
||||
|
||||
// Записываем успешную попытку входа в систему безопасности
|
||||
if (deviceSecurityService.isInitialized()) {
|
||||
deviceSecurityService.recordLoginAttempt(true);
|
||||
}
|
||||
|
||||
// После сохранения данных, но до перенаправления
|
||||
console.log('isAuthenticated ПОСЛЕ входа:', isAuthenticated.value);
|
||||
console.log('Данные пользователя ПОСЛЕ входа:', user.value);
|
||||
@ -93,6 +104,25 @@ async function login(credentials) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка входа:', error.response ? error.response.data : error.message);
|
||||
|
||||
// Записываем неудачную попытку входа в систему безопасности
|
||||
if (deviceSecurityService.isInitialized()) {
|
||||
deviceSecurityService.recordLoginAttempt(false);
|
||||
|
||||
// Если слишком много неудачных попыток, записываем подозрительную активность
|
||||
if (error.response?.status === 429) {
|
||||
deviceSecurityService.recordSuspiciousActivity({
|
||||
type: 'rate_limit_exceeded',
|
||||
endpoint: '/auth/login',
|
||||
details: {
|
||||
email: credentials.email,
|
||||
userAgent: navigator.userAgent
|
||||
},
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаем сообщение об ошибке, чтобы отобразить в компоненте
|
||||
throw error.response ? error.response.data : new Error('Ошибка сети или сервера при входе');
|
||||
}
|
||||
@ -120,6 +150,12 @@ async function logout() {
|
||||
user.value = null;
|
||||
token.value = null;
|
||||
localStorage.removeItem('userToken');
|
||||
|
||||
// Очищаем данные в системе безопасности устройств
|
||||
if (deviceSecurityService.isInitialized()) {
|
||||
deviceSecurityService.clearUserSession();
|
||||
}
|
||||
|
||||
// Удаляем токен из заголовков Axios (если устанавливали его напрямую)
|
||||
// delete apiClient.defaults.headers.common['Authorization']; // apiClient не экспортируется из api.js напрямую для этого
|
||||
// Наш интерсептор запросов в api.js перестанет добавлять токен, так как его не будет в localStorage.
|
||||
@ -211,6 +247,53 @@ function handleAccountUnblocked(data) {
|
||||
console.log('Аккаунт пользователя разблокирован.');
|
||||
}
|
||||
|
||||
// Функция обработки события блокировки устройства
|
||||
function handleDeviceBlocked(data) {
|
||||
console.log('[Auth] Обработка блокировки устройства:', data);
|
||||
|
||||
try {
|
||||
// Создаем объект с информацией о блокировке устройства
|
||||
const deviceBlockInfo = {
|
||||
blocked: true,
|
||||
message: data?.message || 'Доступ с данного устройства заблокирован.',
|
||||
reason: data?.reason || 'Подозрительная активность',
|
||||
timestamp: data?.blockedAt || new Date().toISOString(),
|
||||
deviceId: data?.deviceId || 'unknown'
|
||||
};
|
||||
|
||||
// Сохраняем информацию о блокировке устройства
|
||||
localStorage.setItem('deviceBlockedInfo', JSON.stringify(deviceBlockInfo));
|
||||
|
||||
// Обновляем состояние в сервисе безопасности устройств
|
||||
if (deviceSecurityService.isInitialized()) {
|
||||
deviceSecurityService.handleDeviceBlocked(data);
|
||||
}
|
||||
|
||||
// Немедленно очищаем все данные пользователя
|
||||
console.log('[Auth] Очистка данных пользователя из-за блокировки устройства');
|
||||
user.value = null;
|
||||
token.value = null;
|
||||
localStorage.removeItem('userToken');
|
||||
|
||||
// Устанавливаем флаг в SessionStorage
|
||||
sessionStorage.setItem('forcedLogout', 'device_blocked');
|
||||
|
||||
// Перенаправляем на страницу входа с информацией о блокировке устройства
|
||||
console.log('[Auth] Перенаправление на страницу входа с параметром device blocked');
|
||||
|
||||
window.location.href = '/login?blocked=true&type=device&reason=' +
|
||||
encodeURIComponent(deviceBlockInfo.reason) +
|
||||
'&message=' + encodeURIComponent(deviceBlockInfo.message);
|
||||
|
||||
console.log('[Auth] Пользователь был разлогинен из-за блокировки устройства.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Auth] Ошибка при обработке блокировки устройства:', error);
|
||||
window.location.href = '/login?blocked=true&type=device&emergency=true';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем то, что понадобится в компонентах
|
||||
export function useAuth() {
|
||||
return {
|
||||
@ -222,6 +305,7 @@ export function useAuth() {
|
||||
logout,
|
||||
fetchUser, // Эту функцию нужно будет вызвать при инициализации приложения
|
||||
handleAccountBlocked, // Добавляем функцию обработки блокировки аккаунта
|
||||
handleAccountUnblocked // Добавляем функцию обработки разблокировки аккаунта
|
||||
handleAccountUnblocked, // Добавляем функцию обработки разблокировки аккаунта
|
||||
handleDeviceBlocked // Добавляем функцию обработки блокировки устройства
|
||||
};
|
||||
}
|
1033
src/components/BlockedDevices.vue
Normal file
1033
src/components/BlockedDevices.vue
Normal file
File diff suppressed because it is too large
Load Diff
1282
src/components/admin/BlockedDevicesManagement.vue
Normal file
1282
src/components/admin/BlockedDevicesManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
19
src/main.js
19
src/main.js
@ -4,6 +4,7 @@ import router from './router';
|
||||
import { useAuth } from './auth';
|
||||
import Vue3TouchEvents from 'vue3-touch-events';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import deviceSecurityService from './services/deviceSecurity';
|
||||
|
||||
// Создаем приложение
|
||||
const app = createApp(App);
|
||||
@ -15,11 +16,27 @@ app.use(Vue3TouchEvents);
|
||||
app.use(router);
|
||||
|
||||
// Получаем экземпляр нашего auth "стора"
|
||||
const { fetchUser } = useAuth();
|
||||
const { fetchUser, handleDeviceBlocked } = useAuth();
|
||||
|
||||
// Асинхронная самовызывающаяся функция для инициализации
|
||||
(async () => {
|
||||
try {
|
||||
// Инициализируем систему безопасности устройств
|
||||
console.log('Инициализация системы безопасности устройств...');
|
||||
await deviceSecurityService.initialize();
|
||||
|
||||
// Подписываемся на события блокировки устройства
|
||||
deviceSecurityService.onDeviceBlocked(handleDeviceBlocked);
|
||||
|
||||
console.log('Система безопасности устройств инициализирована');
|
||||
|
||||
// Проверяем, не заблокировано ли устройство
|
||||
if (deviceSecurityService.isDeviceBlocked()) {
|
||||
console.warn('Устройство заблокировано, перенаправляем на страницу входа');
|
||||
window.location.href = '/login?blocked=true&type=device';
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchUser(); // Пытаемся загрузить пользователя по токену из localStorage
|
||||
} catch (error) {
|
||||
console.error("Ошибка при начальной загрузке пользователя в main.js:", error);
|
||||
|
@ -57,6 +57,12 @@ const routes = [
|
||||
component: () => import('../views/PreferencesView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// Страница блокировки устройства
|
||||
{
|
||||
path: '/device-blocked',
|
||||
name: 'DeviceBlocked',
|
||||
component: () => import('../views/DeviceBlockedView.vue'),
|
||||
},
|
||||
// Маршруты для админ-панели
|
||||
{
|
||||
path: '/admin',
|
||||
@ -109,6 +115,12 @@ const routes = [
|
||||
component: () => import('../views/admin/AdminReportDetail.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'blocked-devices',
|
||||
name: 'AdminBlockedDevices',
|
||||
component: () => import('../views/admin/AdminBlockedDevices.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -141,7 +153,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
console.log('[ROUTER GUARD] Пользователь является администратором:', user.value?.isAdmin ? 'Да' : 'Нет');
|
||||
|
||||
// Переадресация администратора сразу после загрузки данных
|
||||
if (user.value?.isAdmin && !to.path.startsWith('/admin')) {
|
||||
if (user.value?.isAdmin && !to.path.startsает('/admin')) {
|
||||
console.log('[ROUTER GUARD] Пользователь - администратор, перенаправляем на панель администратора');
|
||||
return next({ name: 'AdminDashboard' });
|
||||
}
|
||||
|
235
src/services/adminDeviceSecurityService.js
Normal file
235
src/services/adminDeviceSecurityService.js
Normal file
@ -0,0 +1,235 @@
|
||||
import apiClient from './apiClient';
|
||||
|
||||
class AdminDeviceSecurityService {
|
||||
// Получение списка заблокированных устройств
|
||||
async getBlockedDevices(params = {}) {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/blocked-devices', { params });
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения заблокированных устройств:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение статистики по заблокированным устройствам
|
||||
async getDeviceSecurityStats() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/device-security/stats');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения статистики:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Блокировка устройства
|
||||
async blockDevice(deviceData) {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/block-device', {
|
||||
userId: deviceData.userId,
|
||||
deviceFingerprint: deviceData.deviceFingerprint,
|
||||
reason: deviceData.reason,
|
||||
additional: deviceData.additional || {}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка блокировки устройства:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Разблокировка устройства
|
||||
async unblockDevice(deviceFingerprint, reason) {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/unblock-device', {
|
||||
deviceFingerprint,
|
||||
reason
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка разблокировки устройства:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение детальной информации об устройстве
|
||||
async getDeviceDetails(deviceFingerprint) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/device/${deviceFingerprint}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения деталей устройства:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение истории блокировок устройства
|
||||
async getDeviceBlockHistory(deviceFingerprint) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/device/${deviceFingerprint}/history`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения истории блокировок:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение попыток обхода блокировки
|
||||
async getBypassAttempts(deviceFingerprint) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/device/${deviceFingerprint}/bypass-attempts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения попыток обхода:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Массовая блокировка устройств
|
||||
async blockMultipleDevices(devices, reason) {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/block-devices-bulk', {
|
||||
devices,
|
||||
reason
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка массовой блокировки:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт списка заблокированных устройств
|
||||
async exportBlockedDevices(format = 'csv', filters = {}) {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/blocked-devices/export', {
|
||||
params: { format, ...filters },
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Создаем ссылку для скачивания
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `blocked-devices-${new Date().toISOString().split('T')[0]}.${format}`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка экспорта:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение отчета по безопасности устройств
|
||||
async getSecurityReport(period = '30d') {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/device-security/report', {
|
||||
params: { period }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения отчета:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление настроек безопасности устройств
|
||||
async updateSecuritySettings(settings) {
|
||||
try {
|
||||
const response = await apiClient.put('/admin/device-security/settings', settings);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка обновления настроек:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение настроек безопасности устройств
|
||||
async getSecuritySettings() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/device-security/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения настроек:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Поиск устройств по критериям
|
||||
async searchDevices(query) {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/devices/search', {
|
||||
params: { q: query }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка поиска устройств:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение активных сессий пользователя
|
||||
async getUserActiveSessions(userId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/user/${userId}/sessions`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения активных сессий:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Завершение всех сессий пользователя
|
||||
async terminateUserSessions(userId) {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/user/${userId}/terminate-sessions`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка завершения сессий:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Добавление устройства в белый список
|
||||
async whitelistDevice(deviceFingerprint, reason) {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/device/whitelist', {
|
||||
deviceFingerprint,
|
||||
reason
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка добавления в белый список:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление устройства из белого списка
|
||||
async removeFromWhitelist(deviceFingerprint) {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/device/whitelist/${deviceFingerprint}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка удаления из белого списка:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение журнала безопасности
|
||||
async getSecurityLog(filters = {}) {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/security-log', { params: filters });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AdminDeviceSecurityService] Ошибка получения журнала безопасности:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AdminDeviceSecurityService();
|
@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../auth'; // Импортируем функцию авторизации
|
||||
import { isAccountBlocked } from './socketService'; // Импортируем проверку блокировки из socketService
|
||||
import deviceSecurityService from './deviceSecurityService'; // Импортируем сервис безопасности устройств
|
||||
|
||||
// Создаем экземпляр Axios с базовым URL нашего API
|
||||
// Настраиваем базовый URL в зависимости от окружения
|
||||
@ -26,6 +27,12 @@ const checkAccountBlockStatus = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверяем блокировку устройства
|
||||
if (deviceSecurityService && deviceSecurityService.isDeviceBlocked()) {
|
||||
console.warn('[API] Устройство заблокировано, запросы невозможны');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('[API] Ошибка при проверке статуса блокировки:', e);
|
||||
@ -37,81 +44,88 @@ const checkAccountBlockStatus = () => {
|
||||
const getBaseUrl = () => {
|
||||
// Всегда используем относительный путь /api.
|
||||
// В режиме разработки Vite dev server будет проксировать эти запросы согласно настройке server.proxy в vite.config.js.
|
||||
// В продакшене предполагается, что веб-сервер (например, Nginx) настроен аналогично для обработки /api.
|
||||
// return '/api';
|
||||
return import.meta.env.VITE_API_BASE_URL; // Используем переменную окружения Vite
|
||||
return '/api';
|
||||
};
|
||||
|
||||
// Создаем экземпляр Axios
|
||||
const apiClient = axios.create({
|
||||
baseURL: getBaseUrl(),
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// (Опционально, но очень полезно) Перехватчик для добавления JWT токена к запросам
|
||||
// Интерсептор запросов для добавления токена авторизации и device fingerprint
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// Проверяем, заблокирован ли аккаунт, ПЕРЕД выполнением запроса
|
||||
async (config) => {
|
||||
// Проверяем блокировку перед отправкой запроса
|
||||
if (checkAccountBlockStatus()) {
|
||||
// Если аккаунт заблокирован, отменяем запрос
|
||||
const source = axios.CancelToken.source();
|
||||
config.cancelToken = source.token;
|
||||
source.cancel('Запрос отменен из-за блокировки аккаунта');
|
||||
console.error('[API] Запрос отменен из-за блокировки аккаунта:', config.url);
|
||||
|
||||
// Перенаправляем на страницу логина, если пользователь пытается выполнять запросы
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?blocked=true';
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return Promise.reject(new Error('Аккаунт заблокирован'));
|
||||
const error = new Error('Запрос отменен: аккаунт или устройство заблокированы');
|
||||
error.code = 'ACCOUNT_OR_DEVICE_BLOCKED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('userToken'); // Предполагаем, что токен хранится в localStorage
|
||||
// Добавляем токен авторизации
|
||||
const token = localStorage.getItem('userToken');
|
||||
if (token) {
|
||||
console.log('[API] Добавление токена к запросу:', config.url);
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('[API] Токен не найден для запроса:', config.url);
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Добавляем CancelToken к запросу для возможности отмены
|
||||
// Добавляем device fingerprint и информацию об устройстве
|
||||
if (deviceSecurityService && deviceSecurityService.getCurrentFingerprint()) {
|
||||
const fingerprintHeaders = deviceSecurityService.injectFingerprintHeaders();
|
||||
Object.assign(config.headers, fingerprintHeaders);
|
||||
}
|
||||
|
||||
// Создаем токен отмены для этого запроса
|
||||
const requestKey = `${config.method}-${config.url}`;
|
||||
const source = axios.CancelToken.source();
|
||||
config.cancelToken = source.token;
|
||||
|
||||
// Сохраняем источник токена отмены в Map по URL запроса как ключу
|
||||
const requestKey = `${config.method}-${config.url}`;
|
||||
cancelTokenSources.set(requestKey, source);
|
||||
|
||||
console.log(`[API] Отправка ${config.method?.toUpperCase()} запроса:`, config.url);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API] Ошибка в перехватчике запросов:', error);
|
||||
console.error('[API] Ошибка в интерсепторе запросов:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// (Опционально) Перехватчик ответов для обработки глобальных ошибок, например, 401
|
||||
// Интерсептор ответов для обработки ошибок
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Удаляем источник токена отмены после успешного ответа
|
||||
// Удаляем источник токена отмены при успешном ответе
|
||||
const requestKey = `${response.config.method}-${response.config.url}`;
|
||||
cancelTokenSources.delete(requestKey);
|
||||
|
||||
console.log(`[API] Успешный ответ от ${response.config.url}:`, response.status);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
// Удаляем источник токена отмены при ошибке
|
||||
if (error.config) {
|
||||
const requestKey = `${error.config.method}-${error.config.url}`;
|
||||
cancelTokenSources.delete(requestKey);
|
||||
}
|
||||
|
||||
// Проверяем на блокировку аккаунта
|
||||
// Проверяем на блокировку устройства
|
||||
if (error.response && error.response.status === 403) {
|
||||
const errorData = error.response.data;
|
||||
|
||||
// Проверка на блокировку устройства
|
||||
if (errorData && errorData.code === 'DEVICE_BLOCKED') {
|
||||
console.error('[API] Получено уведомление о блокировке устройства:', errorData);
|
||||
|
||||
// Обрабатываем блокировку устройства через deviceSecurityService
|
||||
if (deviceSecurityService && typeof deviceSecurityService.handleDeviceBlock === 'function') {
|
||||
deviceSecurityService.handleDeviceBlock(errorData);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Проверка на признаки блокировки аккаунта в ответе
|
||||
if (errorData && (
|
||||
errorData.blocked === true ||
|
||||
@ -125,30 +139,60 @@ apiClient.interceptors.response.use(
|
||||
const { handleAccountBlocked } = useAuth();
|
||||
if (typeof handleAccountBlocked === 'function') {
|
||||
handleAccountBlocked(errorData);
|
||||
} else {
|
||||
console.error('[API] Функция handleAccountBlocked недоступна');
|
||||
}
|
||||
}, 0);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response && error.response.status === 401) {
|
||||
console.error('[API] Неавторизованный запрос (401):', error.config.url);
|
||||
// Проверяем на ошибку требования device fingerprint
|
||||
if (error.response && error.response.status === 400) {
|
||||
const errorData = error.response.data;
|
||||
if (errorData && errorData.code === 'DEVICE_FINGERPRINT_REQUIRED') {
|
||||
console.warn('[API] Требуется device fingerprint для запроса');
|
||||
|
||||
// Обходим замыкание, чтобы избежать проблем с импортом
|
||||
// При использовании такого подхода, logout будет вызван после текущего цикла выполнения JS
|
||||
// Пытаемся инициализировать систему безопасности устройств
|
||||
if (deviceSecurityService && typeof deviceSecurityService.initialize === 'function') {
|
||||
try {
|
||||
await deviceSecurityService.initialize();
|
||||
console.log('[API] Device fingerprint инициализирован, повторяем запрос');
|
||||
|
||||
// Повторяем запрос с новым fingerprint
|
||||
const originalRequest = error.config;
|
||||
const fingerprintHeaders = deviceSecurityService.injectFingerprintHeaders();
|
||||
Object.assign(originalRequest.headers, fingerprintHeaders);
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch (initError) {
|
||||
console.error('[API] Не удалось инициализировать device fingerprint:', initError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка ошибки токена авторизации
|
||||
if (error.response && error.response.status === 401) {
|
||||
console.warn('[API] Ошибка авторизации, возможно токен истек');
|
||||
|
||||
// Вызываем logout для очистки состояния
|
||||
setTimeout(() => {
|
||||
const { logout } = useAuth();
|
||||
logout();
|
||||
}, 0);
|
||||
if (typeof logout === 'function') {
|
||||
logout();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
console.error(`[API] Ошибка запроса к ${error.config?.url}:`, error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Функция для отмены всех активных запросов
|
||||
// Функция для отмены всех активных запросов (при блокировке)
|
||||
const cancelActiveRequests = () => {
|
||||
console.log('[API] Отмена всех активных запросов. Количество: ' + cancelTokenSources.size);
|
||||
cancelTokenSources.forEach((source, key) => {
|
||||
source.cancel('Запрос отменен из-за блокировки аккаунта');
|
||||
source.cancel(`Запрос ${key} отменен из-за блокировки`);
|
||||
console.log(`[API] Запрос ${key} отменен`);
|
||||
});
|
||||
cancelTokenSources.clear();
|
||||
@ -243,16 +287,36 @@ export default {
|
||||
getReports(params = {}) {
|
||||
return apiClient.get('/admin/reports', { params });
|
||||
},
|
||||
|
||||
getReportById(reportId) {
|
||||
return apiClient.get(`/admin/reports/${reportId}`);
|
||||
},
|
||||
|
||||
updateReportStatus(reportId, updateData) {
|
||||
return apiClient.put(`/admin/reports/${reportId}`, updateData);
|
||||
reviewReport(reportId, reviewData) {
|
||||
return apiClient.put(`/admin/reports/${reportId}/review`, reviewData);
|
||||
},
|
||||
blockUser(userId, blockData) {
|
||||
return apiClient.post(`/admin/users/${userId}/block`, blockData);
|
||||
},
|
||||
unblockUser(userId, reason) {
|
||||
return apiClient.post(`/admin/users/${userId}/unblock`, { reason });
|
||||
},
|
||||
|
||||
getReportsStats() {
|
||||
return apiClient.get('/admin/reports/stats');
|
||||
// Новые методы для управления блокировками устройств
|
||||
checkDeviceBlock(deviceData) {
|
||||
return apiClient.post('/security/check-device', deviceData);
|
||||
},
|
||||
reportSuspiciousActivity(activityData) {
|
||||
return apiClient.post('/security/report-suspicious', activityData);
|
||||
},
|
||||
getBlockedDevices(params = {}) {
|
||||
return apiClient.get('/admin/blocked-devices', { params });
|
||||
},
|
||||
getBlockedDeviceDetails(deviceId) {
|
||||
return apiClient.get(`/admin/blocked-devices/${deviceId}`);
|
||||
},
|
||||
blockDevice(deviceData) {
|
||||
return apiClient.post('/admin/block-device', deviceData);
|
||||
},
|
||||
unblockDevice(deviceFingerprint, reason) {
|
||||
return apiClient.post('/admin/unblock-device', { deviceFingerprint, reason });
|
||||
}
|
||||
};
|
429
src/services/deviceSecurity.js
Normal file
429
src/services/deviceSecurity.js
Normal file
@ -0,0 +1,429 @@
|
||||
import DeviceFingerprint from '../utils/deviceFingerprint.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
/**
|
||||
* Сервис для работы с системой блокировки устройств
|
||||
*/
|
||||
class DeviceSecurityService {
|
||||
constructor() {
|
||||
this.deviceFingerprint = new DeviceFingerprint();
|
||||
this.currentFingerprint = null;
|
||||
this.isBlocked = false;
|
||||
this.initialized = false;
|
||||
this.suspiciousActivities = [];
|
||||
this.lastFingerprintCheck = null;
|
||||
this.checkInterval = null;
|
||||
|
||||
// Настройки мониторинга
|
||||
this.config = {
|
||||
checkIntervalMs: 30000, // Проверка каждые 30 секунд
|
||||
maxSuspiciousActivities: 10,
|
||||
reportThreshold: 5, // Отправлять отчет после 5 подозрительных активностей
|
||||
fingerprintChangeThreshold: 0.1 // Порог изменения отпечатка (10%)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация сервиса безопасности
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
console.log('[DeviceSecurity] Инициализация сервиса безопасности устройств...');
|
||||
|
||||
// Генерируем отпечаток устройства
|
||||
this.currentFingerprint = await this.deviceFingerprint.initialize();
|
||||
|
||||
// Проверяем статус блокировки
|
||||
await this.checkDeviceBlockStatus();
|
||||
|
||||
// Запускаем периодический мониторинг
|
||||
this.startMonitoring();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[DeviceSecurity] Сервис безопасности инициализирован');
|
||||
|
||||
return {
|
||||
fingerprint: this.currentFingerprint,
|
||||
isBlocked: this.isBlocked,
|
||||
initialized: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при инициализации:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка статуса блокировки устройства
|
||||
*/
|
||||
async checkDeviceBlockStatus() {
|
||||
try {
|
||||
const response = await api.post('/security/check-device', {
|
||||
fingerprint: this.currentFingerprint,
|
||||
deviceInfo: this.getDeviceInfo()
|
||||
});
|
||||
|
||||
this.isBlocked = response.data.blocked || false;
|
||||
this.lastFingerprintCheck = Date.now();
|
||||
|
||||
if (this.isBlocked) {
|
||||
console.warn('[DeviceSecurity] Устройство заблокировано:', response.data.reason);
|
||||
this.handleDeviceBlocked(response.data);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[DeviceSecurity] Устройство не заблокировано');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при проверке блокировки:', error);
|
||||
|
||||
// При ошибке API считаем устройство потенциально заблокированным
|
||||
if (error.response?.status === 403) {
|
||||
this.isBlocked = true;
|
||||
this.handleDeviceBlocked(error.response.data);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // При других ошибках разрешаем доступ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации об устройстве
|
||||
*/
|
||||
getDeviceInfo() {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
screenResolution: `${screen.width}x${screen.height}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timestamp: Date.now(),
|
||||
visitCount: this.getVisitCount()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение количества посещений (из localStorage)
|
||||
*/
|
||||
getVisitCount() {
|
||||
try {
|
||||
const visits = localStorage.getItem('device_visits');
|
||||
const count = visits ? parseInt(visits) + 1 : 1;
|
||||
localStorage.setItem('device_visits', count.toString());
|
||||
return count;
|
||||
} catch (e) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск периодического мониторинга
|
||||
*/
|
||||
startMonitoring() {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
}
|
||||
|
||||
this.checkInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.performSecurityCheck();
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при периодической проверке:', error);
|
||||
}
|
||||
}, this.config.checkIntervalMs);
|
||||
|
||||
console.log('[DeviceSecurity] Мониторинг запущен');
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановка мониторинга
|
||||
*/
|
||||
stopMonitoring() {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = null;
|
||||
console.log('[DeviceSecurity] Мониторинг остановлен');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнение периодической проверки безопасности
|
||||
*/
|
||||
async performSecurityCheck() {
|
||||
try {
|
||||
// Проверяем, не изменился ли отпечаток устройства
|
||||
await this.checkFingerprintChanges();
|
||||
|
||||
// Проверяем статус блокировки
|
||||
await this.checkDeviceBlockStatus();
|
||||
|
||||
// Отправляем отчет о подозрительных активностях, если накопилось достаточно
|
||||
if (this.suspiciousActivities.length >= this.config.reportThreshold) {
|
||||
await this.reportSuspiciousActivity();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при периодической проверке:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка изменений отпечатка устройства
|
||||
*/
|
||||
async checkFingerprintChanges() {
|
||||
try {
|
||||
const newFingerprint = await this.deviceFingerprint.refresh();
|
||||
|
||||
if (newFingerprint !== this.currentFingerprint) {
|
||||
console.warn('[DeviceSecurity] Обнаружено изменение отпечатка устройства');
|
||||
|
||||
this.recordSuspiciousActivity({
|
||||
type: 'fingerprint_change',
|
||||
oldFingerprint: this.currentFingerprint.substring(0, 16) + '...',
|
||||
newFingerprint: newFingerprint.substring(0, 16) + '...',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.currentFingerprint = newFingerprint;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при проверке изменений отпечатка:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись подозрительной активности
|
||||
*/
|
||||
recordSuspiciousActivity(activity) {
|
||||
this.suspiciousActivities.push({
|
||||
...activity,
|
||||
id: Date.now() + Math.random(),
|
||||
recorded: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Ограничиваем размер массива
|
||||
if (this.suspiciousActivities.length > this.config.maxSuspiciousActivities) {
|
||||
this.suspiciousActivities = this.suspiciousActivities.slice(-this.config.maxSuspiciousActivities);
|
||||
}
|
||||
|
||||
console.warn('[DeviceSecurity] Зафиксирована подозрительная активность:', activity.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка отчета о подозрительных активностях
|
||||
*/
|
||||
async reportSuspiciousActivity() {
|
||||
try {
|
||||
if (this.suspiciousActivities.length === 0) return;
|
||||
|
||||
console.log('[DeviceSecurity] Отправка отчета о подозрительных активностях...');
|
||||
|
||||
const response = await api.post('/security/report-suspicious', {
|
||||
activities: this.suspiciousActivities,
|
||||
deviceInfo: this.getDeviceInfo(),
|
||||
fingerprint: this.currentFingerprint
|
||||
});
|
||||
|
||||
// Если устройство было заблокировано автоматически
|
||||
if (response.data.blocked) {
|
||||
this.isBlocked = true;
|
||||
this.handleDeviceBlocked(response.data);
|
||||
}
|
||||
|
||||
// Очищаем отправленные активности
|
||||
this.suspiciousActivities = [];
|
||||
|
||||
console.log('[DeviceSecurity] Отчет отправлен, статус:', response.data.action);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при отправке отчета:', error);
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
this.isBlocked = true;
|
||||
this.handleDeviceBlocked(error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка блокировки устройства
|
||||
*/
|
||||
handleDeviceBlocked(blockInfo) {
|
||||
console.error('[DeviceSecurity] Устройство заблокировано:', blockInfo);
|
||||
|
||||
// Останавливаем мониторинг
|
||||
this.stopMonitoring();
|
||||
|
||||
// Очищаем локальные данные
|
||||
this.clearLocalData();
|
||||
|
||||
// Показываем уведомление пользователю
|
||||
this.showBlockedNotification(blockInfo);
|
||||
|
||||
// Перенаправляем на страницу блокировки или выходим
|
||||
this.redirectToBlockedPage(blockInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка локальных данных при блокировке
|
||||
*/
|
||||
clearLocalData() {
|
||||
try {
|
||||
// Очищаем localStorage (кроме critical данных)
|
||||
const keysToKeep = ['device_visits'];
|
||||
const allKeys = Object.keys(localStorage);
|
||||
|
||||
allKeys.forEach(key => {
|
||||
if (!keysToKeep.includes(key)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Очищаем sessionStorage
|
||||
sessionStorage.clear();
|
||||
|
||||
console.log('[DeviceSecurity] Локальные данные очищены');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при очистке данных:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показ уведомления о блокировке
|
||||
*/
|
||||
showBlockedNotification(blockInfo) {
|
||||
const message = blockInfo.message || 'Доступ с данного устройства заблокирован';
|
||||
const reason = blockInfo.reason || 'Нарушение правил безопасности';
|
||||
|
||||
// Используем встроенное API уведомлений браузера, если доступно
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Устройство заблокировано', {
|
||||
body: message,
|
||||
icon: '/pwa-192x192.png'
|
||||
});
|
||||
}
|
||||
|
||||
// Также показываем alert для гарантии
|
||||
alert(`Устройство заблокировано\n\nПричина: ${reason}\n\n${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Перенаправление на страницу блокировки
|
||||
*/
|
||||
redirectToBlockedPage(blockInfo) {
|
||||
// Создаем URL с информацией о блокировке
|
||||
const params = new URLSearchParams({
|
||||
reason: blockInfo.reason || 'security_violation',
|
||||
code: blockInfo.code || 'DEVICE_BLOCKED',
|
||||
blocked_at: blockInfo.blockedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
// Перенаправляем на страницу блокировки
|
||||
window.location.href = `/blocked?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мониторинг множественных попыток входа
|
||||
*/
|
||||
recordLoginAttempt(success) {
|
||||
const timestamp = Date.now();
|
||||
const attempts = this.getRecentAttempts();
|
||||
|
||||
attempts.push({ timestamp, success });
|
||||
|
||||
// Сохраняем последние 10 попыток
|
||||
const recentAttempts = attempts.filter(a => timestamp - a.timestamp < 300000); // 5 минут
|
||||
localStorage.setItem('login_attempts', JSON.stringify(recentAttempts));
|
||||
|
||||
// Проверяем на подозрительную активность
|
||||
const failedAttempts = recentAttempts.filter(a => !a.success);
|
||||
if (failedAttempts.length >= 3) {
|
||||
this.recordSuspiciousActivity({
|
||||
type: 'multiple_login_attempts',
|
||||
count: failedAttempts.length,
|
||||
timespan: '5 minutes',
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение недавних попыток входа
|
||||
*/
|
||||
getRecentAttempts() {
|
||||
try {
|
||||
const attempts = localStorage.getItem('login_attempts');
|
||||
return attempts ? JSON.parse(attempts) : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка инициализации
|
||||
*/
|
||||
isInitialized() {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущего отпечатка
|
||||
*/
|
||||
getCurrentFingerprint() {
|
||||
return this.currentFingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка блокировки
|
||||
*/
|
||||
isDeviceBlocked() {
|
||||
return this.isBlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение компонентов отпечатка (для отладки)
|
||||
*/
|
||||
getFingerprintComponents() {
|
||||
return this.deviceFingerprint.getComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статистики подозрительных активностей
|
||||
*/
|
||||
getSuspiciousActivitiesStats() {
|
||||
return {
|
||||
total: this.suspiciousActivities.length,
|
||||
activities: this.suspiciousActivities.slice(),
|
||||
lastCheck: this.lastFingerprintCheck
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка истории (для отладки)
|
||||
*/
|
||||
clearHistory() {
|
||||
this.suspiciousActivities = [];
|
||||
localStorage.removeItem('login_attempts');
|
||||
console.log('[DeviceSecurity] История очищена');
|
||||
}
|
||||
|
||||
/**
|
||||
* Деструктор
|
||||
*/
|
||||
destroy() {
|
||||
this.stopMonitoring();
|
||||
this.suspiciousActivities = [];
|
||||
this.initialized = false;
|
||||
console.log('[DeviceSecurity] Сервис уничтожен');
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем единственный экземпляр сервиса
|
||||
const deviceSecurityService = new DeviceSecurityService();
|
||||
|
||||
export default deviceSecurityService;
|
338
src/services/deviceSecurityService.js
Normal file
338
src/services/deviceSecurityService.js
Normal file
@ -0,0 +1,338 @@
|
||||
import deviceFingerprint from '@/utils/deviceFingerprint';
|
||||
|
||||
/**
|
||||
* Сервис для работы с блокировкой устройств на клиентской стороне
|
||||
*/
|
||||
class DeviceSecurityService {
|
||||
constructor() {
|
||||
this.currentFingerprint = null;
|
||||
this.isBlocked = false;
|
||||
this.lastCheck = null;
|
||||
this.checkInterval = 5 * 60 * 1000; // 5 минут
|
||||
this.blockCheckCallbacks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация сервиса безопасности устройств
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
console.log('[DeviceSecurity] Инициализация системы безопасности устройств');
|
||||
|
||||
// Генерируем fingerprint устройства
|
||||
this.currentFingerprint = await deviceFingerprint.getDeviceFingerprint();
|
||||
deviceFingerprint.saveFingerprint(this.currentFingerprint);
|
||||
|
||||
console.log('[DeviceSecurity] Device fingerprint создан:', this.currentFingerprint.substring(0, 16) + '...');
|
||||
|
||||
// Проверяем, изменился ли fingerprint с последнего визита
|
||||
const hasChanged = await deviceFingerprint.hasChanged();
|
||||
if (hasChanged) {
|
||||
console.log('[DeviceSecurity] Device fingerprint изменился с последнего визита');
|
||||
this.handleFingerprintChange();
|
||||
}
|
||||
|
||||
// Устанавливаем периодическую проверку
|
||||
this.startPeriodicCheck();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка инициализации:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущий fingerprint устройства
|
||||
*/
|
||||
getCurrentFingerprint() {
|
||||
return this.currentFingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, заблокировано ли текущее устройство
|
||||
*/
|
||||
async checkDeviceBlock() {
|
||||
try {
|
||||
if (!this.currentFingerprint) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Отправляем запрос на сервер для проверки блокировки
|
||||
const response = await fetch('/api/security/check-device', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Fingerprint': this.currentFingerprint
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fingerprint: this.currentFingerprint,
|
||||
deviceInfo: await this.getDeviceInfo()
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.status === 403 && result.blocked) {
|
||||
this.isBlocked = true;
|
||||
this.handleDeviceBlock(result);
|
||||
return { blocked: true, reason: result.reason };
|
||||
}
|
||||
|
||||
this.isBlocked = false;
|
||||
this.lastCheck = Date.now();
|
||||
return { blocked: false };
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при проверке блокировки устройства:', error);
|
||||
return { blocked: false, error: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает дополнительную информацию об устройстве
|
||||
*/
|
||||
async getDeviceInfo() {
|
||||
try {
|
||||
const basicFingerprint = await deviceFingerprint.getBasicFingerprint();
|
||||
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
screenResolution: `${screen.width}x${screen.height}`,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timestamp: Date.now(),
|
||||
visitCount: deviceFingerprint.getVisitCount(),
|
||||
// Дополнительные метрики для анализа
|
||||
connection: basicFingerprint.connection,
|
||||
webgl: basicFingerprint.webgl?.renderer || 'unknown',
|
||||
canvas: basicFingerprint.canvas ? 'available' : 'unavailable'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при получении информации об устройстве:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает блокировку устройства
|
||||
*/
|
||||
handleDeviceBlock(blockInfo) {
|
||||
console.log('[DeviceSecurity] Устройство заблокировано:', blockInfo);
|
||||
|
||||
// Сохраняем информацию о блокировке
|
||||
localStorage.setItem('deviceBlocked', JSON.stringify({
|
||||
blocked: true,
|
||||
reason: blockInfo.reason,
|
||||
blockedAt: blockInfo.blockedAt,
|
||||
fingerprint: this.currentFingerprint,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
// Очищаем чувствительные данные
|
||||
this.clearSensitiveData();
|
||||
|
||||
// Уведомляем подписчиков о блокировке
|
||||
this.notifyBlockCallbacks(blockInfo);
|
||||
|
||||
// Перенаправляем на страницу блокировки
|
||||
this.redirectToBlockedPage(blockInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает изменение fingerprint устройства
|
||||
*/
|
||||
handleFingerprintChange() {
|
||||
console.log('[DeviceSecurity] Device fingerprint изменился - возможна попытка обхода');
|
||||
|
||||
// Логируем подозрительную активность
|
||||
const suspiciousActivity = {
|
||||
type: 'fingerprint_change',
|
||||
oldFingerprint: deviceFingerprint.loadFingerprint()?.fingerprint,
|
||||
newFingerprint: this.currentFingerprint,
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent
|
||||
};
|
||||
|
||||
// Сохраняем в localStorage для отправки на сервер
|
||||
const existing = JSON.parse(localStorage.getItem('suspiciousActivity') || '[]');
|
||||
existing.push(suspiciousActivity);
|
||||
localStorage.setItem('suspiciousActivity', JSON.stringify(existing.slice(-10))); // Последние 10 событий
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает чувствительные данные при блокировке
|
||||
*/
|
||||
clearSensitiveData() {
|
||||
try {
|
||||
// Очищаем пользовательские данные
|
||||
localStorage.removeItem('userToken');
|
||||
localStorage.removeItem('userSettings');
|
||||
localStorage.removeItem('userPreferences');
|
||||
|
||||
// Очищаем кеши
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => {
|
||||
if (name.includes('user-data') || name.includes('api-cache')) {
|
||||
caches.delete(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Очищаем IndexedDB (если используется)
|
||||
if ('indexedDB' in window) {
|
||||
// Здесь можно добавить очистку IndexedDB
|
||||
}
|
||||
|
||||
console.log('[DeviceSecurity] Чувствительные данные очищены');
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при очистке данных:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет fingerprint в заголовки запросов
|
||||
*/
|
||||
injectFingerprintHeaders(headers = {}) {
|
||||
if (this.currentFingerprint) {
|
||||
headers['X-Device-Fingerprint'] = this.currentFingerprint;
|
||||
headers['X-Screen-Resolution'] = `${screen.width}x${screen.height}`;
|
||||
headers['X-Platform'] = navigator.platform;
|
||||
headers['X-Language'] = navigator.language;
|
||||
headers['X-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает периодическую проверку блокировки
|
||||
*/
|
||||
startPeriodicCheck() {
|
||||
setInterval(async () => {
|
||||
if (!this.isBlocked && this.currentFingerprint) {
|
||||
await this.checkDeviceBlock();
|
||||
}
|
||||
}, this.checkInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет callback для уведомления о блокировке
|
||||
*/
|
||||
onDeviceBlock(callback) {
|
||||
this.blockCheckCallbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомляет подписчиков о блокировке
|
||||
*/
|
||||
notifyBlockCallbacks(blockInfo) {
|
||||
this.blockCheckCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(blockInfo);
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка в callback блокировки:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Перенаправляет на страницу блокировки
|
||||
*/
|
||||
redirectToBlockedPage(blockInfo) {
|
||||
const params = new URLSearchParams({
|
||||
blocked: 'true',
|
||||
reason: blockInfo.reason || 'Устройство заблокировано',
|
||||
type: 'device'
|
||||
});
|
||||
|
||||
// Используем window.location для принудительного перенаправления
|
||||
window.location.href = `/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, заблокировано ли устройство (синхронная проверка)
|
||||
*/
|
||||
isDeviceBlocked() {
|
||||
const stored = localStorage.getItem('deviceBlocked');
|
||||
if (stored) {
|
||||
try {
|
||||
const blockInfo = JSON.parse(stored);
|
||||
return blockInfo.blocked === true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return this.isBlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет подозрительную активность на сервер
|
||||
*/
|
||||
async reportSuspiciousActivity() {
|
||||
try {
|
||||
const activities = JSON.parse(localStorage.getItem('suspiciousActivity') || '[]');
|
||||
if (activities.length === 0) return;
|
||||
|
||||
const response = await fetch('/api/security/report-suspicious', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.injectFingerprintHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
activities,
|
||||
deviceInfo: await this.getDeviceInfo()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.removeItem('suspiciousActivity');
|
||||
console.log('[DeviceSecurity] Подозрительная активность отправлена на сервер');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при отправке подозрительной активности:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет целостность fingerprint
|
||||
*/
|
||||
async verifyFingerprintIntegrity() {
|
||||
try {
|
||||
const currentFingerprint = await deviceFingerprint.getDeviceFingerprint();
|
||||
|
||||
if (this.currentFingerprint !== currentFingerprint) {
|
||||
console.warn('[DeviceSecurity] Обнаружено изменение fingerprint во время сессии');
|
||||
this.handleFingerprintChange();
|
||||
this.currentFingerprint = currentFingerprint;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DeviceSecurity] Ошибка при проверке целостности fingerprint:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает метрики безопасности для отправки на сервер
|
||||
*/
|
||||
getSecurityMetrics() {
|
||||
return {
|
||||
fingerprintStability: this.lastCheck ? Date.now() - this.lastCheck : 0,
|
||||
visitCount: deviceFingerprint.getVisitCount(),
|
||||
blockStatus: this.isBlocked,
|
||||
lastFingerprintCheck: this.lastCheck,
|
||||
suspiciousActivityCount: JSON.parse(localStorage.getItem('suspiciousActivity') || '[]').length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем единственный экземпляр сервиса
|
||||
const deviceSecurityService = new DeviceSecurityService();
|
||||
|
||||
export default deviceSecurityService;
|
394
src/utils/deviceFingerprint.js
Normal file
394
src/utils/deviceFingerprint.js
Normal file
@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Утилита для создания уникального отпечатка устройства (Device Fingerprinting)
|
||||
* Используется для блокировки на уровне железа
|
||||
*/
|
||||
|
||||
class DeviceFingerprint {
|
||||
constructor() {
|
||||
this.components = new Map();
|
||||
this.fingerprint = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация и создание отпечатка устройства
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
console.log('[DeviceFingerprint] Начинаем инициализацию отпечатка устройства...');
|
||||
|
||||
// Очищаем предыдущие компоненты
|
||||
this.components.clear();
|
||||
|
||||
// Собираем компоненты отпечатка
|
||||
await this.collectScreenInfo();
|
||||
await this.collectNavigatorInfo();
|
||||
await this.collectCanvasFingerprint();
|
||||
await this.collectWebGLFingerprint();
|
||||
await this.collectAudioFingerprint();
|
||||
await this.collectTimezoneInfo();
|
||||
await this.collectLanguageInfo();
|
||||
await this.collectPlatformInfo();
|
||||
await this.collectHardwareInfo();
|
||||
await this.collectStorageInfo();
|
||||
await this.collectNetworkInfo();
|
||||
|
||||
// Генерируем финальный отпечаток
|
||||
this.fingerprint = await this.generateFingerprint();
|
||||
this.initialized = true;
|
||||
|
||||
console.log('[DeviceFingerprint] Отпечаток устройства создан:', this.fingerprint.substring(0, 16) + '...');
|
||||
return this.fingerprint;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceFingerprint] Ошибка при создании отпечатка:', error);
|
||||
// Создаем базовый отпечаток даже при ошибках
|
||||
this.fingerprint = await this.createFallbackFingerprint();
|
||||
this.initialized = true;
|
||||
return this.fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация об экране
|
||||
*/
|
||||
async collectScreenInfo() {
|
||||
try {
|
||||
this.components.set('screen', {
|
||||
width: screen.width,
|
||||
height: screen.height,
|
||||
availWidth: screen.availWidth,
|
||||
availHeight: screen.availHeight,
|
||||
colorDepth: screen.colorDepth,
|
||||
pixelDepth: screen.pixelDepth,
|
||||
devicePixelRatio: window.devicePixelRatio || 1
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить информацию об экране:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация о навигаторе
|
||||
*/
|
||||
async collectNavigatorInfo() {
|
||||
try {
|
||||
this.components.set('navigator', {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
languages: navigator.languages ? Array.from(navigator.languages) : [],
|
||||
platform: navigator.platform,
|
||||
cookieEnabled: navigator.cookieEnabled,
|
||||
doNotTrack: navigator.doNotTrack,
|
||||
maxTouchPoints: navigator.maxTouchPoints || 0,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 0,
|
||||
deviceMemory: navigator.deviceMemory || 0
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить информацию навигатора:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas fingerprinting
|
||||
*/
|
||||
async collectCanvasFingerprint() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Рисуем уникальный паттерн
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillStyle = '#f60';
|
||||
ctx.fillRect(125, 1, 62, 20);
|
||||
ctx.fillStyle = '#069';
|
||||
ctx.fillText('Device fingerprint text 🔐', 2, 15);
|
||||
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
||||
ctx.fillText('Device fingerprint text 🔐', 4, 17);
|
||||
|
||||
// Добавляем геометрические фигуры
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.fillStyle = 'rgb(255,0,255)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
const canvasData = canvas.toDataURL();
|
||||
this.components.set('canvas', this.hashString(canvasData));
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось создать canvas fingerprint:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL fingerprinting
|
||||
*/
|
||||
async collectWebGLFingerprint() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
|
||||
if (gl) {
|
||||
const webglInfo = {
|
||||
version: gl.getParameter(gl.VERSION),
|
||||
vendor: gl.getParameter(gl.VENDOR),
|
||||
renderer: gl.getParameter(gl.RENDERER),
|
||||
shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
|
||||
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
|
||||
maxViewportDims: gl.getParameter(gl.MAX_VIEWPORT_DIMS)
|
||||
};
|
||||
|
||||
this.components.set('webgl', webglInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить WebGL информацию:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio fingerprinting
|
||||
*/
|
||||
async collectAudioFingerprint() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
if (!window.AudioContext && !window.webkitAudioContext) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new AudioContext();
|
||||
|
||||
const oscillator = context.createOscillator();
|
||||
const analyser = context.createAnalyser();
|
||||
const gainNode = context.createGain();
|
||||
const scriptProcessor = context.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
oscillator.type = 'triangle';
|
||||
oscillator.frequency.value = 10000;
|
||||
|
||||
gainNode.gain.value = 0;
|
||||
|
||||
oscillator.connect(analyser);
|
||||
analyser.connect(scriptProcessor);
|
||||
scriptProcessor.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
scriptProcessor.onaudioprocess = (event) => {
|
||||
const bins = new Float32Array(analyser.frequencyBinCount);
|
||||
analyser.getFloatFrequencyData(bins);
|
||||
|
||||
const audioHash = this.hashString(bins.slice(0, 50).join(','));
|
||||
this.components.set('audio', audioHash);
|
||||
|
||||
oscillator.disconnect();
|
||||
context.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
oscillator.start();
|
||||
|
||||
// Таймаут на случай проблем
|
||||
setTimeout(() => {
|
||||
try {
|
||||
oscillator.disconnect();
|
||||
context.close();
|
||||
} catch (e) {}
|
||||
resolve();
|
||||
}, 1000);
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось создать audio fingerprint:', e);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация о временной зоне
|
||||
*/
|
||||
async collectTimezoneInfo() {
|
||||
try {
|
||||
this.components.set('timezone', {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezoneOffset: new Date().getTimezoneOffset(),
|
||||
locale: Intl.DateTimeFormat().resolvedOptions().locale
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить информацию о временной зоне:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Языковые настройки
|
||||
*/
|
||||
async collectLanguageInfo() {
|
||||
try {
|
||||
this.components.set('language', {
|
||||
language: navigator.language,
|
||||
languages: navigator.languages ? Array.from(navigator.languages) : []
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить языковую информацию:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Платформа и ОС
|
||||
*/
|
||||
async collectPlatformInfo() {
|
||||
try {
|
||||
this.components.set('platform', {
|
||||
platform: navigator.platform,
|
||||
oscpu: navigator.oscpu,
|
||||
appVersion: navigator.appVersion
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить информацию о платформе:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Аппаратная информация
|
||||
*/
|
||||
async collectHardwareInfo() {
|
||||
try {
|
||||
this.components.set('hardware', {
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 0,
|
||||
deviceMemory: navigator.deviceMemory || 0,
|
||||
maxTouchPoints: navigator.maxTouchPoints || 0
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить аппаратную информацию:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация о хранилище
|
||||
*/
|
||||
async collectStorageInfo() {
|
||||
try {
|
||||
let storageQuota = 0;
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
storageQuota = estimate.quota || 0;
|
||||
}
|
||||
|
||||
this.components.set('storage', {
|
||||
localStorage: !!window.localStorage,
|
||||
sessionStorage: !!window.sessionStorage,
|
||||
indexedDB: !!window.indexedDB,
|
||||
storageQuota
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить информацию о хранилище:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сетевая информация
|
||||
*/
|
||||
async collectNetworkInfo() {
|
||||
try {
|
||||
const networkInfo = {};
|
||||
|
||||
if ('connection' in navigator) {
|
||||
const conn = navigator.connection;
|
||||
networkInfo.effectiveType = conn.effectiveType;
|
||||
networkInfo.downlink = conn.downlink;
|
||||
networkInfo.rtt = conn.rtt;
|
||||
}
|
||||
|
||||
this.components.set('network', networkInfo);
|
||||
} catch (e) {
|
||||
console.warn('[DeviceFingerprint] Не удалось получить сетевую информацию:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация финального отпечатка
|
||||
*/
|
||||
async generateFingerprint() {
|
||||
const componentsString = JSON.stringify(Array.from(this.components.entries()).sort());
|
||||
return await this.hashString(componentsString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание резервного отпечатка при ошибках
|
||||
*/
|
||||
async createFallbackFingerprint() {
|
||||
const fallbackData = {
|
||||
userAgent: navigator.userAgent,
|
||||
screen: `${screen.width}x${screen.height}`,
|
||||
timezone: new Date().getTimezoneOffset(),
|
||||
language: navigator.language,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return await this.hashString(JSON.stringify(fallbackData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Хеширование строки
|
||||
*/
|
||||
async hashString(str) {
|
||||
if (crypto && crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} else {
|
||||
// Fallback для старых браузеров
|
||||
return this.simpleHash(str);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Простое хеширование для старых браузеров
|
||||
*/
|
||||
simpleHash(str) {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash.toString();
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Конвертируем в 32-битное целое
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущего отпечатка
|
||||
*/
|
||||
getFingerprint() {
|
||||
return this.fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка инициализации
|
||||
*/
|
||||
isInitialized() {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение компонентов отпечатка (для отладки)
|
||||
*/
|
||||
getComponents() {
|
||||
return Object.fromEntries(this.components);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление отпечатка
|
||||
*/
|
||||
async refresh() {
|
||||
return await this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем класс
|
||||
export default DeviceFingerprint;
|
666
src/views/DeviceBlockedPage.vue
Normal file
666
src/views/DeviceBlockedPage.vue
Normal file
@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<div class="device-blocked-page">
|
||||
<div class="blocked-container">
|
||||
<div class="blocked-icon">
|
||||
<svg viewBox="0 0 24 24" width="80" height="80" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM13 17h-2v-6h2v6zm0-8h-2V7h2v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="blocked-content">
|
||||
<h1>Устройство заблокировано</h1>
|
||||
|
||||
<div class="block-info">
|
||||
<p class="main-message">
|
||||
Ваше устройство было заблокировано администрацией сервиса.
|
||||
</p>
|
||||
|
||||
<div v-if="blockInfo" class="block-details">
|
||||
<div class="detail-item">
|
||||
<strong>Причина блокировки:</strong>
|
||||
<span>{{ blockInfo.reason || 'Нарушение правил сервиса' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="blockInfo.blockedAt" class="detail-item">
|
||||
<strong>Дата блокировки:</strong>
|
||||
<span>{{ formatDate(blockInfo.blockedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="blockInfo.deviceId" class="detail-item">
|
||||
<strong>ID устройства:</strong>
|
||||
<code>{{ blockInfo.deviceId.slice(-8) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>Что это означает?</h3>
|
||||
<ul>
|
||||
<li>Ваше устройство заблокировано для использования сервиса</li>
|
||||
<li>Доступ к функциям приложения ограничен</li>
|
||||
<li>Блокировка может быть связана с нарушением правил</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="contact-section">
|
||||
<h3>Как обжаловать блокировку?</h3>
|
||||
<p>
|
||||
Если вы считаете, что блокировка была применена ошибочно,
|
||||
вы можете обратиться в службу поддержки:
|
||||
</p>
|
||||
|
||||
<div class="contact-methods">
|
||||
<div class="contact-item">
|
||||
<strong>Email:</strong>
|
||||
<a href="mailto:support@datingapp.com">support@datingapp.com</a>
|
||||
</div>
|
||||
|
||||
<div class="contact-item">
|
||||
<strong>Telegram:</strong>
|
||||
<a href="https://t.me/datingapp_support" target="_blank">@datingapp_support</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="appeal-form">
|
||||
<h4>Отправить обращение</h4>
|
||||
<form @submit.prevent="submitAppeal" class="appeal-form-content">
|
||||
<div class="form-group">
|
||||
<label for="email">Ваш email:</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
v-model="appealForm.email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Сообщение:</label>
|
||||
<textarea
|
||||
id="message"
|
||||
v-model="appealForm.message"
|
||||
required
|
||||
placeholder="Опишите ситуацию и почему считаете блокировку ошибочной..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="appealLoading"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ appealLoading ? 'Отправка...' : 'Отправить обращение' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="appealSent" class="appeal-success">
|
||||
<p>✅ Ваше обращение отправлено! Мы рассмотрим его в ближайшее время.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-info">
|
||||
<h3>Дополнительная информация</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<h4>Правила сервиса</h4>
|
||||
<p>Ознакомьтесь с правилами использования нашего сервиса</p>
|
||||
<a href="/terms" class="link">Читать правила</a>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h4>Безопасность</h4>
|
||||
<p>Узнайте больше о мерах безопасности</p>
|
||||
<a href="/security" class="link">О безопасности</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bypass-warning">
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<div class="warning-text">
|
||||
<strong>Внимание!</strong>
|
||||
Попытки обойти блокировку устройства могут привести к
|
||||
дополнительным ограничениям и продлению срока блокировки.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import deviceSecurityService from '@/services/deviceSecurityService';
|
||||
|
||||
export default {
|
||||
name: 'DeviceBlockedPage',
|
||||
setup() {
|
||||
const blockInfo = ref(null);
|
||||
const appealSent = ref(false);
|
||||
const appealLoading = ref(false);
|
||||
|
||||
const appealForm = reactive({
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
// Загрузка информации о блокировке
|
||||
const loadBlockInfo = () => {
|
||||
try {
|
||||
// Получаем информацию о блокировке из deviceSecurityService
|
||||
if (deviceSecurityService && typeof deviceSecurityService.getBlockInfo === 'function') {
|
||||
const info = deviceSecurityService.getBlockInfo();
|
||||
blockInfo.value = info;
|
||||
console.log('[DeviceBlockedPage] Информация о блокировке:', info);
|
||||
}
|
||||
|
||||
// Также проверяем localStorage
|
||||
const storedBlockInfo = localStorage.getItem('deviceBlockInfo');
|
||||
if (storedBlockInfo) {
|
||||
try {
|
||||
const parsedInfo = JSON.parse(storedBlockInfo);
|
||||
if (!blockInfo.value) {
|
||||
blockInfo.value = parsedInfo;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DeviceBlockedPage] Ошибка парсинга данных блокировки:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет информации, создаем базовую
|
||||
if (!blockInfo.value) {
|
||||
blockInfo.value = {
|
||||
reason: 'Нарушение правил сервиса',
|
||||
blockedAt: new Date().toISOString(),
|
||||
deviceId: 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceBlockedPage] Ошибка загрузки информации о блокировке:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Отправка обращения
|
||||
const submitAppeal = async () => {
|
||||
if (!appealForm.email || !appealForm.message) {
|
||||
alert('Пожалуйста, заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
appealLoading.value = true;
|
||||
|
||||
try {
|
||||
// Здесь можно отправить обращение на email или в систему
|
||||
// Пока что симулируем отправку
|
||||
console.log('[DeviceBlockedPage] Отправка обращения:', appealForm);
|
||||
|
||||
// Симуляция отправки
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Сохраняем информацию о том, что обращение отправлено
|
||||
localStorage.setItem('appealSent', JSON.stringify({
|
||||
email: appealForm.email,
|
||||
message: appealForm.message,
|
||||
sentAt: new Date().toISOString(),
|
||||
deviceId: blockInfo.value?.deviceId
|
||||
}));
|
||||
|
||||
appealSent.value = true;
|
||||
|
||||
// Очищаем форму
|
||||
appealForm.email = '';
|
||||
appealForm.message = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DeviceBlockedPage] Ошибка отправки обращения:', error);
|
||||
alert('Ошибка при отправке обращения. Попробуйте позже.');
|
||||
} finally {
|
||||
appealLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Неизвестно';
|
||||
|
||||
try {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (err) {
|
||||
return 'Неверная дата';
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка, было ли уже отправлено обращение
|
||||
const checkAppealStatus = () => {
|
||||
try {
|
||||
const storedAppeal = localStorage.getItem('appealSent');
|
||||
if (storedAppeal) {
|
||||
const appealData = JSON.parse(storedAppeal);
|
||||
|
||||
// Проверяем, было ли обращение отправлено в последние 24 часа
|
||||
const sentAt = new Date(appealData.sentAt);
|
||||
const now = new Date();
|
||||
const hoursDiff = (now - sentAt) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff < 24) {
|
||||
appealSent.value = true;
|
||||
appealForm.email = appealData.email;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeviceBlockedPage] Ошибка проверки статуса обращения:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadBlockInfo();
|
||||
checkAppealStatus();
|
||||
|
||||
// Отправляем событие аналитики
|
||||
if (typeof gtag !== 'undefined') {
|
||||
gtag('event', 'device_blocked_page_view', {
|
||||
'event_category': 'security',
|
||||
'event_label': 'device_blocked'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
blockInfo,
|
||||
appealForm,
|
||||
appealSent,
|
||||
appealLoading,
|
||||
submitAppeal,
|
||||
formatDate
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-blocked-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.blocked-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blocked-icon {
|
||||
text-align: center;
|
||||
padding: 40px 20px 20px;
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blocked-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.blocked-content h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin: 0 0 25px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.block-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.main-message {
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
margin: 0 0 20px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block-details {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-item strong {
|
||||
color: #333;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.detail-item code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.help-section h3 {
|
||||
color: #333;
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.help-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-section li {
|
||||
padding: 8px 0;
|
||||
color: #555;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.help-section li::before {
|
||||
content: '•';
|
||||
color: #dc3545;
|
||||
font-size: 18px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.contact-section h3 {
|
||||
color: #333;
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.contact-methods {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.contact-item strong {
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.contact-item a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contact-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.appeal-form {
|
||||
margin-top: 25px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.appeal-form h4 {
|
||||
color: #333;
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.appeal-form-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.appeal-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.additional-info {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.additional-info h3 {
|
||||
color: #333;
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-card h4 {
|
||||
color: #333;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-card p {
|
||||
color: #666;
|
||||
margin: 0 0 15px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bypass-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #856404;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warning-text strong {
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.device-blocked-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.blocked-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.blocked-content h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-item strong {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.contact-item strong {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bypass-warning {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.blocked-container {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.blocked-icon {
|
||||
padding: 30px 20px 15px;
|
||||
}
|
||||
|
||||
.blocked-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.block-info,
|
||||
.contact-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -28,6 +28,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Добавляем показ информации о блокировке устройства -->
|
||||
<div v-if="deviceBlocked" class="blocked-account-message">
|
||||
<i class="bi-shield-exclamation"></i>
|
||||
<div class="blocked-message-content">
|
||||
<h3>Доступ с устройства заблокирован</h3>
|
||||
<p>{{ deviceBlockedInfo.message }}</p>
|
||||
<p v-if="deviceBlockedInfo.reason" class="block-reason">
|
||||
<strong>Причина:</strong> {{ deviceBlockedInfo.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="email">
|
||||
@ -95,27 +107,38 @@
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { useAuth } from '@/auth';
|
||||
import { useRoute } from 'vue-router';
|
||||
import deviceSecurityService from '@/services/deviceSecurity';
|
||||
|
||||
const route = useRoute();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const errorMessage = ref('');
|
||||
const loading = ref(false);
|
||||
const securityLoading = ref(true);
|
||||
const blockedInfo = ref({
|
||||
blocked: false,
|
||||
message: 'Ваш аккаунт был заблокирован администратором.',
|
||||
reason: 'Нарушение правил сервиса',
|
||||
timestamp: ''
|
||||
});
|
||||
const deviceBlockedInfo = ref({
|
||||
blocked: false,
|
||||
message: 'Доступ с данного устройства заблокирован.',
|
||||
reason: 'Подозрительная активность',
|
||||
timestamp: ''
|
||||
});
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
// Проверяем, был ли аккаунт заблокирован
|
||||
// Проверяем, был ли аккаунт или устройство заблокированы
|
||||
const accountBlocked = computed(() => {
|
||||
// Проверяем query параметр и информацию из localStorage
|
||||
return route.query.blocked === 'true' || blockedInfo.value.blocked;
|
||||
});
|
||||
|
||||
const deviceBlocked = computed(() => {
|
||||
return route.query.type === 'device' || deviceBlockedInfo.value.blocked || deviceSecurityService.isDeviceBlocked();
|
||||
});
|
||||
|
||||
// Функция для блокировки прокрутки страницы
|
||||
const preventScroll = () => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
@ -132,18 +155,59 @@ const enableScroll = () => {
|
||||
document.body.style.width = '';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
preventScroll(); // Блокируем прокрутку при монтировании компонента
|
||||
onMounted(async () => {
|
||||
preventScroll();
|
||||
|
||||
// Проверяем, есть ли информация о блокировке в localStorage
|
||||
try {
|
||||
console.log('[LoginView] Инициализация системы безопасности устройств...');
|
||||
|
||||
// Инициализируем систему безопасности устройств
|
||||
const securityResult = await deviceSecurityService.initialize();
|
||||
|
||||
console.log('[LoginView] Результат инициализации безопасности:', securityResult);
|
||||
|
||||
// Проверяем, не заблокировано ли устройство
|
||||
if (securityResult.isBlocked) {
|
||||
deviceBlockedInfo.value = {
|
||||
blocked: true,
|
||||
message: 'Доступ с данного устройства заблокирован',
|
||||
reason: 'Устройство находится в черном списке',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[LoginView] Ошибка при инициализации системы безопасности:', error);
|
||||
|
||||
// При критической ошибке системы безопасности показываем предупреждение
|
||||
if (error.response?.status === 403) {
|
||||
deviceBlockedInfo.value = {
|
||||
blocked: true,
|
||||
message: 'Доступ с данного устройства заблокирован',
|
||||
reason: error.response.data?.reason || 'Подозрительная активность',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
securityLoading.value = false;
|
||||
}
|
||||
|
||||
// Проверяем query параметры для блокировки устройства
|
||||
if (route.query.type === 'device' && route.query.blocked === 'true') {
|
||||
deviceBlockedInfo.value = {
|
||||
blocked: true,
|
||||
message: decodeURIComponent(route.query.message || 'Доступ с данного устройства заблокирован'),
|
||||
reason: decodeURIComponent(route.query.reason || 'Подозрительная активность'),
|
||||
timestamp: route.query.blocked_at || new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// Проверяем информацию о блокировке аккаунта из localStorage
|
||||
const storedBlockInfo = localStorage.getItem('accountBlockedInfo');
|
||||
if (storedBlockInfo) {
|
||||
try {
|
||||
const parsedInfo = JSON.parse(storedBlockInfo);
|
||||
blockedInfo.value = parsedInfo;
|
||||
|
||||
// Удаляем дублирующее сообщение об ошибке, так как эта информация уже
|
||||
// отображается в блоке blocked-account-message выше кнопки "Войти"
|
||||
} catch (e) {
|
||||
console.error('Ошибка при парсинге информации о блокировке:', e);
|
||||
}
|
||||
@ -151,7 +215,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
enableScroll(); // Восстанавливаем прокрутку при размонтировании компонента
|
||||
enableScroll();
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
@ -165,9 +229,96 @@ const handleLogin = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await login({ email: email.value, password: password.value });
|
||||
// Проверяем, инициализирована ли система безопасности
|
||||
if (!deviceSecurityService.isInitialized()) {
|
||||
errorMessage.value = 'Система безопасности не инициализирована. Обновите страницу.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем блокировку устройства перед попыткой входа
|
||||
if (deviceSecurityService.isDeviceBlocked()) {
|
||||
errorMessage.value = 'Доступ с данного устройства заблокирован.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем отпечаток устройства для отправки на сервер
|
||||
const deviceFingerprint = deviceSecurityService.getCurrentFingerprint();
|
||||
|
||||
if (!deviceFingerprint) {
|
||||
errorMessage.value = 'Не удалось создать отпечаток устройства. Обновите страницу.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Выполняем вход с информацией о устройстве
|
||||
const loginResult = await login({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
deviceFingerprint: deviceFingerprint,
|
||||
deviceInfo: {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
screenResolution: `${screen.width}x${screen.height}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Записываем успешную попытку входа
|
||||
deviceSecurityService.recordLoginAttempt(true);
|
||||
|
||||
console.log('[LoginView] Успешный вход:', loginResult);
|
||||
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || 'Произошла неизвестная ошибка при входе.';
|
||||
console.error('[LoginView] Ошибка при входе:', error);
|
||||
|
||||
// Записываем неудачную попытку входа
|
||||
deviceSecurityService.recordLoginAttempt(false);
|
||||
|
||||
// Проверяем, не связана ли ошибка с блокировкой устройства
|
||||
if (error.response?.status === 403) {
|
||||
const responseData = error.response.data;
|
||||
|
||||
if (responseData?.code === 'DEVICE_BLOCKED') {
|
||||
deviceBlockedInfo.value = {
|
||||
blocked: true,
|
||||
message: responseData.message || 'Доступ с данного устройства заблокирован',
|
||||
reason: responseData.reason || 'Подозрительная активность',
|
||||
timestamp: responseData.blockedAt || new Date().toISOString()
|
||||
};
|
||||
|
||||
// Обновляем состояние в сервисе безопасности
|
||||
deviceSecurityService.handleDeviceBlocked(responseData);
|
||||
|
||||
} else if (responseData?.code === 'ACCOUNT_BLOCKED') {
|
||||
blockedInfo.value = {
|
||||
blocked: true,
|
||||
message: responseData.message || 'Ваш аккаунт был заблокирован',
|
||||
reason: responseData.reason || 'Нарушение правил сервиса',
|
||||
timestamp: responseData.blockedAt || new Date().toISOString()
|
||||
};
|
||||
} else {
|
||||
errorMessage.value = responseData?.message || 'Доступ запрещен.';
|
||||
}
|
||||
} else if (error.response?.status === 429) {
|
||||
// Слишком много попыток входа
|
||||
errorMessage.value = 'Слишком много попыток входа. Попробуйте позже.';
|
||||
|
||||
// Записываем подозрительную активность
|
||||
deviceSecurityService.recordSuspiciousActivity({
|
||||
type: 'rate_limit_exceeded',
|
||||
endpoint: '/auth/login',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
} else if (error.response?.status === 401) {
|
||||
errorMessage.value = 'Неверный email или пароль.';
|
||||
} else {
|
||||
errorMessage.value = error.message || 'Произошла неизвестная ошибка при входе.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
682
src/views/admin/AdminBlockedDevices.vue
Normal file
682
src/views/admin/AdminBlockedDevices.vue
Normal file
@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<div class="admin-blocked-devices">
|
||||
<div class="page-header">
|
||||
<h1>Заблокированные устройства</h1>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ blockedDevices.length }}</div>
|
||||
<div class="stat-label">Всего заблокировано</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-filters">
|
||||
<div class="search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Поиск по device ID, причине или дате..."
|
||||
@input="filterDevices"
|
||||
>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select v-model="statusFilter" @change="filterDevices">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="expired">Истекшие</option>
|
||||
</select>
|
||||
<select v-model="reasonFilter" @change="filterDevices">
|
||||
<option value="">Все причины</option>
|
||||
<option value="multiple_violations">Множественные нарушения</option>
|
||||
<option value="spam">Спам</option>
|
||||
<option value="harassment">Домогательства</option>
|
||||
<option value="fake_profile">Фейковый профиль</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="devices-table" v-if="filteredDevices.length > 0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>Причина</th>
|
||||
<th>Заблокировано</th>
|
||||
<th>Истекает</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in paginatedDevices" :key="device._id">
|
||||
<td>
|
||||
<div class="device-id">
|
||||
<code>{{ device.deviceId.substring(0, 16) }}...</code>
|
||||
<button
|
||||
@click="copyToClipboard(device.deviceId)"
|
||||
class="copy-btn"
|
||||
title="Копировать полный ID"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="reason-badge" :class="device.reason">
|
||||
{{ getReasonText(device.reason) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(device.blockedAt) }}</td>
|
||||
<td>
|
||||
<span v-if="device.expiresAt" :class="{ 'expired': isExpired(device.expiresAt) }">
|
||||
{{ formatDate(device.expiresAt) }}
|
||||
</span>
|
||||
<span v-else class="permanent">Навсегда</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="getDeviceStatus(device)">
|
||||
{{ getDeviceStatusText(device) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button
|
||||
@click="viewDeviceDetails(device)"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
Детали
|
||||
</button>
|
||||
<button
|
||||
@click="unblockDevice(device._id)"
|
||||
class="btn btn-sm btn-danger"
|
||||
:disabled="isExpired(device.expiresAt)"
|
||||
>
|
||||
Разблокировать
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Пагинация -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
@click="currentPage--"
|
||||
:disabled="currentPage === 1"
|
||||
class="btn btn-outline"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Страница {{ currentPage }} из {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="currentPage++"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="btn btn-outline"
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<div class="no-data-icon">🚫</div>
|
||||
<h3>Заблокированных устройств не найдено</h3>
|
||||
<p>{{ searchQuery ? 'Попробуйте изменить параметры поиска' : 'Пока нет заблокированных устройств' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно с деталями устройства -->
|
||||
<div v-if="selectedDevice" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>Детали заблокированного устройства</h3>
|
||||
<button @click="closeModal" class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="device-details">
|
||||
<div class="detail-row">
|
||||
<label>Device ID:</label>
|
||||
<code>{{ selectedDevice.deviceId }}</code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<label>Причина блокировки:</label>
|
||||
<span class="reason-badge" :class="selectedDevice.reason">
|
||||
{{ getReasonText(selectedDevice.reason) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedDevice.description">
|
||||
<label>Описание:</label>
|
||||
<p>{{ selectedDevice.description }}</p>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<label>Дата блокировки:</label>
|
||||
<span>{{ formatDate(selectedDevice.blockedAt) }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedDevice.expiresAt">
|
||||
<label>Дата истечения:</label>
|
||||
<span :class="{ 'expired': isExpired(selectedDevice.expiresAt) }">
|
||||
{{ formatDate(selectedDevice.expiresAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<label>Статус:</label>
|
||||
<span class="status-badge" :class="getDeviceStatus(selectedDevice)">
|
||||
{{ getDeviceStatusText(selectedDevice) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedDevice.blockedBy">
|
||||
<label>Заблокировал:</label>
|
||||
<span>{{ selectedDevice.blockedBy.username || selectedDevice.blockedBy.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="closeModal" class="btn btn-outline">Закрыть</button>
|
||||
<button
|
||||
@click="unblockDevice(selectedDevice._id)"
|
||||
class="btn btn-danger"
|
||||
:disabled="isExpired(selectedDevice.expiresAt)"
|
||||
>
|
||||
Разблокировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from '@/services/api'
|
||||
|
||||
export default {
|
||||
name: 'AdminBlockedDevices',
|
||||
data() {
|
||||
return {
|
||||
blockedDevices: [],
|
||||
filteredDevices: [],
|
||||
searchQuery: '',
|
||||
statusFilter: '',
|
||||
reasonFilter: '',
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
selectedDevice: null,
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
paginatedDevices() {
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage
|
||||
const end = start + this.itemsPerPage
|
||||
return this.filteredDevices.slice(start, end)
|
||||
},
|
||||
totalPages() {
|
||||
return Math.ceil(this.filteredDevices.length / this.itemsPerPage)
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadBlockedDevices()
|
||||
},
|
||||
methods: {
|
||||
async loadBlockedDevices() {
|
||||
try {
|
||||
this.loading = true
|
||||
const response = await api.getBlockedDevices()
|
||||
this.blockedDevices = response.data.devices || []
|
||||
this.filteredDevices = [...this.blockedDevices]
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки заблокированных устройств:', error)
|
||||
this.$toast.error('Ошибка загрузки данных')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
filterDevices() {
|
||||
let filtered = [...this.blockedDevices]
|
||||
|
||||
// Поиск по тексту
|
||||
if (this.searchQuery) {
|
||||
const query = this.searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(device =>
|
||||
device.deviceId.toLowerCase().includes(query) ||
|
||||
this.getReasonText(device.reason).toLowerCase().includes(query) ||
|
||||
(device.description && device.description.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
// Фильтр по статусу
|
||||
if (this.statusFilter) {
|
||||
filtered = filtered.filter(device => {
|
||||
const status = this.getDeviceStatus(device)
|
||||
return status === this.statusFilter
|
||||
})
|
||||
}
|
||||
|
||||
// Фильтр по причине
|
||||
if (this.reasonFilter) {
|
||||
filtered = filtered.filter(device => device.reason === this.reasonFilter)
|
||||
}
|
||||
|
||||
this.filteredDevices = filtered
|
||||
this.currentPage = 1
|
||||
},
|
||||
getDeviceStatus(device) {
|
||||
if (device.expiresAt && this.isExpired(device.expiresAt)) {
|
||||
return 'expired'
|
||||
}
|
||||
return 'active'
|
||||
},
|
||||
getDeviceStatusText(device) {
|
||||
const status = this.getDeviceStatus(device)
|
||||
return {
|
||||
active: 'Активная',
|
||||
expired: 'Истекшая'
|
||||
}[status]
|
||||
},
|
||||
getReasonText(reason) {
|
||||
const reasons = {
|
||||
multiple_violations: 'Множественные нарушения',
|
||||
spam: 'Спам',
|
||||
harassment: 'Домогательства',
|
||||
fake_profile: 'Фейковый профиль',
|
||||
other: 'Другое'
|
||||
}
|
||||
return reasons[reason] || reason
|
||||
},
|
||||
isExpired(expiresAt) {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
},
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleString('ru-RU')
|
||||
},
|
||||
viewDeviceDetails(device) {
|
||||
this.selectedDevice = device
|
||||
},
|
||||
closeModal() {
|
||||
this.selectedDevice = null
|
||||
},
|
||||
async unblockDevice(deviceId) {
|
||||
if (!confirm('Вы уверены, что хотите разблокировать это устройство?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.unblockDevice(deviceId)
|
||||
this.$toast.success('Устройство разблокировано')
|
||||
await this.loadBlockedDevices()
|
||||
this.closeModal()
|
||||
} catch (error) {
|
||||
console.error('Ошибка разблокировки устройства:', error)
|
||||
this.$toast.error('Ошибка разблокировки устройства')
|
||||
}
|
||||
},
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
this.$toast.success('ID скопирован в буфер обмена')
|
||||
} catch (error) {
|
||||
console.error('Ошибка копирования в буфер обмена:', error)
|
||||
this.$toast.error('Ошибка копирования')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-blocked-devices {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.devices-table {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.device-id code {
|
||||
background: #f1f3f4;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.reason-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reason-badge.multiple_violations {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.reason-badge.spam {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.reason-badge.harassment {
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.reason-badge.fake_profile {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.reason-badge.other {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-badge.expired {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.permanent {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expired {
|
||||
color: #6b7280;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-row label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.detail-row code {
|
||||
background: #f1f3f4;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.devices-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user