добавление блокировки по железу
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,
|
updateReportStatus,
|
||||||
getReportsStats
|
getReportsStats
|
||||||
} = require('../controllers/reportController');
|
} = require('../controllers/reportController');
|
||||||
|
const {
|
||||||
|
blockDevice,
|
||||||
|
unblockDeviceAdmin,
|
||||||
|
getBlockedDevicesList,
|
||||||
|
getBlockedDeviceDetails
|
||||||
|
} = require('../controllers/deviceSecurityController');
|
||||||
|
|
||||||
// Все маршруты защищены middleware для проверки авторизации и прав администратора
|
// Все маршруты защищены middleware для проверки авторизации и прав администратора
|
||||||
router.use(protect, adminMiddleware);
|
router.use(protect, adminMiddleware);
|
||||||
@ -32,4 +38,10 @@ router.get('/conversations', adminController.getAllConversations);
|
|||||||
router.get('/conversations/:id', adminController.getConversationById); // Новый маршрут для получения диалога по ID
|
router.get('/conversations/:id', adminController.getConversationById); // Новый маршрут для получения диалога по ID
|
||||||
router.get('/conversations/:id/messages', adminController.getConversationMessages);
|
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;
|
module.exports = router;
|
@ -11,6 +11,7 @@ const {
|
|||||||
|
|
||||||
// Импортируем middleware для защиты маршрутов
|
// Импортируем middleware для защиты маршрутов
|
||||||
const { protect } = require('../middleware/authMiddleware');
|
const { protect } = require('../middleware/authMiddleware');
|
||||||
|
const { checkDeviceBlock, recordDeviceActivity } = require('../middleware/deviceBlockMiddleware');
|
||||||
|
|
||||||
// --- НАЧАЛО ИЗМЕНЕНИЯ ---
|
// --- НАЧАЛО ИЗМЕНЕНИЯ ---
|
||||||
// Дополнительная отладка: Проверяем, что protect действительно функция
|
// Дополнительная отладка: Проверяем, что 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 loginUser:', typeof loginUser);
|
||||||
console.log('[DEBUG] authRoutes.js - typeof getMe:', typeof getMe);
|
console.log('[DEBUG] authRoutes.js - typeof getMe:', typeof getMe);
|
||||||
console.log('[DEBUG] authRoutes.js - typeof protect:', typeof protect);
|
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;
|
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 actionRoutes = require('./routes/actionRoutes'); // 1. Импортируем actionRoutes
|
||||||
const adminRoutes = require('./routes/adminRoutes'); // Импортируем маршруты админа
|
const adminRoutes = require('./routes/adminRoutes'); // Импортируем маршруты админа
|
||||||
const reportRoutes = require('./routes/reportRoutes'); // Импортируем маршруты для жалоб
|
const reportRoutes = require('./routes/reportRoutes'); // Импортируем маршруты для жалоб
|
||||||
|
const securityRoutes = require('./routes/securityRoutes'); // Импортируем маршруты безопасности
|
||||||
const Message = require('./models/Message'); // Импорт модели Message
|
const Message = require('./models/Message'); // Импорт модели Message
|
||||||
const Conversation = require('./models/Conversation'); // Импорт модели Conversation
|
const Conversation = require('./models/Conversation'); // Импорт модели Conversation
|
||||||
const initAdminAccount = require('./utils/initAdmin'); // Импорт инициализации админ-аккаунта
|
const initAdminAccount = require('./utils/initAdmin'); // Импорт инициализации админ-аккаунта
|
||||||
@ -87,6 +88,7 @@ app.use('/api/actions', actionRoutes);
|
|||||||
app.use('/api/conversations', conversationRoutes);
|
app.use('/api/conversations', conversationRoutes);
|
||||||
app.use('/api/reports', reportRoutes); // Подключаем маршруты для жалоб
|
app.use('/api/reports', reportRoutes); // Подключаем маршруты для жалоб
|
||||||
app.use('/api/admin', adminRoutes); // Подключаем маршруты для админ-панели
|
app.use('/api/admin', adminRoutes); // Подключаем маршруты для админ-панели
|
||||||
|
app.use('/api/security', securityRoutes); // Подключаем маршруты для системы безопасности устройств
|
||||||
|
|
||||||
// Socket.IO логика
|
// Socket.IO логика
|
||||||
let activeUsers = [];
|
let activeUsers = [];
|
||||||
|
86
src/auth.js
86
src/auth.js
@ -1,6 +1,7 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import api from './services/api'; // Наш API сервис
|
import api from './services/api'; // Наш API сервис
|
||||||
import router from './router'; // Vue Router для перенаправлений
|
import router from './router'; // Vue Router для перенаправлений
|
||||||
|
import deviceSecurityService from './services/deviceSecurity'; // Система безопасности устройств
|
||||||
|
|
||||||
// Реактивные переменные для состояния пользователя и токена
|
// Реактивные переменные для состояния пользователя и токена
|
||||||
const user = ref(null); // Данные пользователя (или null, если не вошел)
|
const user = ref(null); // Данные пользователя (или null, если не вошел)
|
||||||
@ -58,6 +59,11 @@ async function login(credentials) {
|
|||||||
try {
|
try {
|
||||||
console.log('Попытка входа с учетными данными:', { email: credentials.email });
|
console.log('Попытка входа с учетными данными:', { email: credentials.email });
|
||||||
|
|
||||||
|
// Проверяем, не заблокировано ли устройство перед попыткой входа
|
||||||
|
if (deviceSecurityService.isDeviceBlocked()) {
|
||||||
|
throw new Error('Доступ с данного устройства заблокирован');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.login(credentials);
|
const response = await api.login(credentials);
|
||||||
console.log('Ответ сервера при входе:', response);
|
console.log('Ответ сервера при входе:', response);
|
||||||
|
|
||||||
@ -79,6 +85,11 @@ async function login(credentials) {
|
|||||||
// Сохраняем данные пользователя и токен
|
// Сохраняем данные пользователя и токен
|
||||||
setUserData(userData, userToken);
|
setUserData(userData, userToken);
|
||||||
|
|
||||||
|
// Записываем успешную попытку входа в систему безопасности
|
||||||
|
if (deviceSecurityService.isInitialized()) {
|
||||||
|
deviceSecurityService.recordLoginAttempt(true);
|
||||||
|
}
|
||||||
|
|
||||||
// После сохранения данных, но до перенаправления
|
// После сохранения данных, но до перенаправления
|
||||||
console.log('isAuthenticated ПОСЛЕ входа:', isAuthenticated.value);
|
console.log('isAuthenticated ПОСЛЕ входа:', isAuthenticated.value);
|
||||||
console.log('Данные пользователя ПОСЛЕ входа:', user.value);
|
console.log('Данные пользователя ПОСЛЕ входа:', user.value);
|
||||||
@ -93,6 +104,25 @@ async function login(credentials) {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка входа:', error.response ? error.response.data : error.message);
|
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('Ошибка сети или сервера при входе');
|
throw error.response ? error.response.data : new Error('Ошибка сети или сервера при входе');
|
||||||
}
|
}
|
||||||
@ -120,6 +150,12 @@ async function logout() {
|
|||||||
user.value = null;
|
user.value = null;
|
||||||
token.value = null;
|
token.value = null;
|
||||||
localStorage.removeItem('userToken');
|
localStorage.removeItem('userToken');
|
||||||
|
|
||||||
|
// Очищаем данные в системе безопасности устройств
|
||||||
|
if (deviceSecurityService.isInitialized()) {
|
||||||
|
deviceSecurityService.clearUserSession();
|
||||||
|
}
|
||||||
|
|
||||||
// Удаляем токен из заголовков Axios (если устанавливали его напрямую)
|
// Удаляем токен из заголовков Axios (если устанавливали его напрямую)
|
||||||
// delete apiClient.defaults.headers.common['Authorization']; // apiClient не экспортируется из api.js напрямую для этого
|
// delete apiClient.defaults.headers.common['Authorization']; // apiClient не экспортируется из api.js напрямую для этого
|
||||||
// Наш интерсептор запросов в api.js перестанет добавлять токен, так как его не будет в localStorage.
|
// Наш интерсептор запросов в api.js перестанет добавлять токен, так как его не будет в localStorage.
|
||||||
@ -211,6 +247,53 @@ function handleAccountUnblocked(data) {
|
|||||||
console.log('Аккаунт пользователя разблокирован.');
|
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() {
|
export function useAuth() {
|
||||||
return {
|
return {
|
||||||
@ -222,6 +305,7 @@ export function useAuth() {
|
|||||||
logout,
|
logout,
|
||||||
fetchUser, // Эту функцию нужно будет вызвать при инициализации приложения
|
fetchUser, // Эту функцию нужно будет вызвать при инициализации приложения
|
||||||
handleAccountBlocked, // Добавляем функцию обработки блокировки аккаунта
|
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 { useAuth } from './auth';
|
||||||
import Vue3TouchEvents from 'vue3-touch-events';
|
import Vue3TouchEvents from 'vue3-touch-events';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
import deviceSecurityService from './services/deviceSecurity';
|
||||||
|
|
||||||
// Создаем приложение
|
// Создаем приложение
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
@ -15,11 +16,27 @@ app.use(Vue3TouchEvents);
|
|||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
// Получаем экземпляр нашего auth "стора"
|
// Получаем экземпляр нашего auth "стора"
|
||||||
const { fetchUser } = useAuth();
|
const { fetchUser, handleDeviceBlocked } = useAuth();
|
||||||
|
|
||||||
// Асинхронная самовызывающаяся функция для инициализации
|
// Асинхронная самовызывающаяся функция для инициализации
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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
|
await fetchUser(); // Пытаемся загрузить пользователя по токену из localStorage
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Ошибка при начальной загрузке пользователя в main.js:", error);
|
console.error("Ошибка при начальной загрузке пользователя в main.js:", error);
|
||||||
|
@ -57,6 +57,12 @@ const routes = [
|
|||||||
component: () => import('../views/PreferencesView.vue'),
|
component: () => import('../views/PreferencesView.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
// Страница блокировки устройства
|
||||||
|
{
|
||||||
|
path: '/device-blocked',
|
||||||
|
name: 'DeviceBlocked',
|
||||||
|
component: () => import('../views/DeviceBlockedView.vue'),
|
||||||
|
},
|
||||||
// Маршруты для админ-панели
|
// Маршруты для админ-панели
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@ -109,6 +115,12 @@ const routes = [
|
|||||||
component: () => import('../views/admin/AdminReportDetail.vue'),
|
component: () => import('../views/admin/AdminReportDetail.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
props: 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 ? 'Да' : 'Нет');
|
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] Пользователь - администратор, перенаправляем на панель администратора');
|
console.log('[ROUTER GUARD] Пользователь - администратор, перенаправляем на панель администратора');
|
||||||
return next({ name: 'AdminDashboard' });
|
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 axios from 'axios';
|
||||||
import { useAuth } from '../auth'; // Импортируем функцию авторизации
|
import { useAuth } from '../auth'; // Импортируем функцию авторизации
|
||||||
import { isAccountBlocked } from './socketService'; // Импортируем проверку блокировки из socketService
|
import { isAccountBlocked } from './socketService'; // Импортируем проверку блокировки из socketService
|
||||||
|
import deviceSecurityService from './deviceSecurityService'; // Импортируем сервис безопасности устройств
|
||||||
|
|
||||||
// Создаем экземпляр Axios с базовым URL нашего API
|
// Создаем экземпляр Axios с базовым URL нашего API
|
||||||
// Настраиваем базовый URL в зависимости от окружения
|
// Настраиваем базовый URL в зависимости от окружения
|
||||||
@ -26,6 +27,12 @@ const checkAccountBlockStatus = () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем блокировку устройства
|
||||||
|
if (deviceSecurityService && deviceSecurityService.isDeviceBlocked()) {
|
||||||
|
console.warn('[API] Устройство заблокировано, запросы невозможны');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[API] Ошибка при проверке статуса блокировки:', e);
|
console.error('[API] Ошибка при проверке статуса блокировки:', e);
|
||||||
@ -37,81 +44,88 @@ const checkAccountBlockStatus = () => {
|
|||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
// Всегда используем относительный путь /api.
|
// Всегда используем относительный путь /api.
|
||||||
// В режиме разработки Vite dev server будет проксировать эти запросы согласно настройке server.proxy в vite.config.js.
|
// В режиме разработки Vite dev server будет проксировать эти запросы согласно настройке server.proxy в vite.config.js.
|
||||||
// В продакшене предполагается, что веб-сервер (например, Nginx) настроен аналогично для обработки /api.
|
return '/api';
|
||||||
// return '/api';
|
|
||||||
return import.meta.env.VITE_API_BASE_URL; // Используем переменную окружения Vite
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Создаем экземпляр Axios
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: getBaseUrl(),
|
baseURL: getBaseUrl(),
|
||||||
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// (Опционально, но очень полезно) Перехватчик для добавления JWT токена к запросам
|
// Интерсептор запросов для добавления токена авторизации и device fingerprint
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
async (config) => {
|
||||||
// Проверяем, заблокирован ли аккаунт, ПЕРЕД выполнением запроса
|
// Проверяем блокировку перед отправкой запроса
|
||||||
if (checkAccountBlockStatus()) {
|
if (checkAccountBlockStatus()) {
|
||||||
// Если аккаунт заблокирован, отменяем запрос
|
const error = new Error('Запрос отменен: аккаунт или устройство заблокированы');
|
||||||
const source = axios.CancelToken.source();
|
error.code = 'ACCOUNT_OR_DEVICE_BLOCKED';
|
||||||
config.cancelToken = source.token;
|
throw error;
|
||||||
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 token = localStorage.getItem('userToken'); // Предполагаем, что токен хранится в localStorage
|
// Добавляем токен авторизации
|
||||||
|
const token = localStorage.getItem('userToken');
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log('[API] Добавление токена к запросу:', config.url);
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
config.headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
} else {
|
|
||||||
console.log('[API] Токен не найден для запроса:', config.url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем 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();
|
const source = axios.CancelToken.source();
|
||||||
config.cancelToken = source.token;
|
config.cancelToken = source.token;
|
||||||
|
|
||||||
// Сохраняем источник токена отмены в Map по URL запроса как ключу
|
|
||||||
const requestKey = `${config.method}-${config.url}`;
|
|
||||||
cancelTokenSources.set(requestKey, source);
|
cancelTokenSources.set(requestKey, source);
|
||||||
|
|
||||||
|
console.log(`[API] Отправка ${config.method?.toUpperCase()} запроса:`, config.url);
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('[API] Ошибка в перехватчике запросов:', error);
|
console.error('[API] Ошибка в интерсепторе запросов:', error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// (Опционально) Перехватчик ответов для обработки глобальных ошибок, например, 401
|
// Интерсептор ответов для обработки ошибок
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// Удаляем источник токена отмены после успешного ответа
|
// Удаляем источник токена отмены при успешном ответе
|
||||||
const requestKey = `${response.config.method}-${response.config.url}`;
|
const requestKey = `${response.config.method}-${response.config.url}`;
|
||||||
cancelTokenSources.delete(requestKey);
|
cancelTokenSources.delete(requestKey);
|
||||||
|
|
||||||
|
console.log(`[API] Успешный ответ от ${response.config.url}:`, response.status);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
// Удаляем источник токена отмены при ошибке
|
// Удаляем источник токена отмены при ошибке
|
||||||
if (error.config) {
|
if (error.config) {
|
||||||
const requestKey = `${error.config.method}-${error.config.url}`;
|
const requestKey = `${error.config.method}-${error.config.url}`;
|
||||||
cancelTokenSources.delete(requestKey);
|
cancelTokenSources.delete(requestKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем на блокировку аккаунта
|
// Проверяем на блокировку устройства
|
||||||
if (error.response && error.response.status === 403) {
|
if (error.response && error.response.status === 403) {
|
||||||
const errorData = error.response.data;
|
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 && (
|
if (errorData && (
|
||||||
errorData.blocked === true ||
|
errorData.blocked === true ||
|
||||||
@ -125,30 +139,60 @@ apiClient.interceptors.response.use(
|
|||||||
const { handleAccountBlocked } = useAuth();
|
const { handleAccountBlocked } = useAuth();
|
||||||
if (typeof handleAccountBlocked === 'function') {
|
if (typeof handleAccountBlocked === 'function') {
|
||||||
handleAccountBlocked(errorData);
|
handleAccountBlocked(errorData);
|
||||||
|
} else {
|
||||||
|
console.error('[API] Функция handleAccountBlocked недоступна');
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем на ошибку требования 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 для запроса');
|
||||||
|
|
||||||
|
// Пытаемся инициализировать систему безопасности устройств
|
||||||
|
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) {
|
if (error.response && error.response.status === 401) {
|
||||||
console.error('[API] Неавторизованный запрос (401):', error.config.url);
|
console.warn('[API] Ошибка авторизации, возможно токен истек');
|
||||||
|
|
||||||
// Обходим замыкание, чтобы избежать проблем с импортом
|
// Вызываем logout для очистки состояния
|
||||||
// При использовании такого подхода, logout будет вызван после текущего цикла выполнения JS
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
if (typeof logout === 'function') {
|
||||||
logout();
|
logout();
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[API] Ошибка запроса к ${error.config?.url}:`, error.response?.status, error.response?.data);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Функция для отмены всех активных запросов
|
// Функция для отмены всех активных запросов (при блокировке)
|
||||||
const cancelActiveRequests = () => {
|
const cancelActiveRequests = () => {
|
||||||
console.log('[API] Отмена всех активных запросов. Количество: ' + cancelTokenSources.size);
|
|
||||||
cancelTokenSources.forEach((source, key) => {
|
cancelTokenSources.forEach((source, key) => {
|
||||||
source.cancel('Запрос отменен из-за блокировки аккаунта');
|
source.cancel(`Запрос ${key} отменен из-за блокировки`);
|
||||||
console.log(`[API] Запрос ${key} отменен`);
|
console.log(`[API] Запрос ${key} отменен`);
|
||||||
});
|
});
|
||||||
cancelTokenSources.clear();
|
cancelTokenSources.clear();
|
||||||
@ -243,16 +287,36 @@ export default {
|
|||||||
getReports(params = {}) {
|
getReports(params = {}) {
|
||||||
return apiClient.get('/admin/reports', { params });
|
return apiClient.get('/admin/reports', { params });
|
||||||
},
|
},
|
||||||
|
|
||||||
getReportById(reportId) {
|
getReportById(reportId) {
|
||||||
return apiClient.get(`/admin/reports/${reportId}`);
|
return apiClient.get(`/admin/reports/${reportId}`);
|
||||||
},
|
},
|
||||||
|
reviewReport(reportId, reviewData) {
|
||||||
updateReportStatus(reportId, updateData) {
|
return apiClient.put(`/admin/reports/${reportId}/review`, reviewData);
|
||||||
return apiClient.put(`/admin/reports/${reportId}`, updateData);
|
},
|
||||||
|
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>
|
</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">
|
<form @submit.prevent="handleLogin" class="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">
|
<label for="email">
|
||||||
@ -95,27 +107,38 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { useAuth } from '@/auth';
|
import { useAuth } from '@/auth';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import deviceSecurityService from '@/services/deviceSecurity';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const securityLoading = ref(true);
|
||||||
const blockedInfo = ref({
|
const blockedInfo = ref({
|
||||||
blocked: false,
|
blocked: false,
|
||||||
message: 'Ваш аккаунт был заблокирован администратором.',
|
message: 'Ваш аккаунт был заблокирован администратором.',
|
||||||
reason: 'Нарушение правил сервиса',
|
reason: 'Нарушение правил сервиса',
|
||||||
timestamp: ''
|
timestamp: ''
|
||||||
});
|
});
|
||||||
|
const deviceBlockedInfo = ref({
|
||||||
|
blocked: false,
|
||||||
|
message: 'Доступ с данного устройства заблокирован.',
|
||||||
|
reason: 'Подозрительная активность',
|
||||||
|
timestamp: ''
|
||||||
|
});
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
// Проверяем, был ли аккаунт заблокирован
|
// Проверяем, был ли аккаунт или устройство заблокированы
|
||||||
const accountBlocked = computed(() => {
|
const accountBlocked = computed(() => {
|
||||||
// Проверяем query параметр и информацию из localStorage
|
|
||||||
return route.query.blocked === 'true' || blockedInfo.value.blocked;
|
return route.query.blocked === 'true' || blockedInfo.value.blocked;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deviceBlocked = computed(() => {
|
||||||
|
return route.query.type === 'device' || deviceBlockedInfo.value.blocked || deviceSecurityService.isDeviceBlocked();
|
||||||
|
});
|
||||||
|
|
||||||
// Функция для блокировки прокрутки страницы
|
// Функция для блокировки прокрутки страницы
|
||||||
const preventScroll = () => {
|
const preventScroll = () => {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@ -132,18 +155,59 @@ const enableScroll = () => {
|
|||||||
document.body.style.width = '';
|
document.body.style.width = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
preventScroll(); // Блокируем прокрутку при монтировании компонента
|
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');
|
const storedBlockInfo = localStorage.getItem('accountBlockedInfo');
|
||||||
if (storedBlockInfo) {
|
if (storedBlockInfo) {
|
||||||
try {
|
try {
|
||||||
const parsedInfo = JSON.parse(storedBlockInfo);
|
const parsedInfo = JSON.parse(storedBlockInfo);
|
||||||
blockedInfo.value = parsedInfo;
|
blockedInfo.value = parsedInfo;
|
||||||
|
|
||||||
// Удаляем дублирующее сообщение об ошибке, так как эта информация уже
|
|
||||||
// отображается в блоке blocked-account-message выше кнопки "Войти"
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Ошибка при парсинге информации о блокировке:', e);
|
console.error('Ошибка при парсинге информации о блокировке:', e);
|
||||||
}
|
}
|
||||||
@ -151,7 +215,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
enableScroll(); // Восстанавливаем прокрутку при размонтировании компонента
|
enableScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@ -165,9 +229,96 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
|
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 || 'Произошла неизвестная ошибка при входе.';
|
errorMessage.value = error.message || 'Произошла неизвестная ошибка при входе.';
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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