488 lines
17 KiB
Markdown
488 lines
17 KiB
Markdown
# 🔐 ПЛАН РЕАЛИЗАЦИИ БЕЗОПАСНОЙ СИСТЕМЫ API КЛЮЧЕЙ
|
||
|
||
> **Дата:** 2025-09-18
|
||
> **Проект:** SFERA
|
||
> **Статус:** ГОТОВ К РЕАЛИЗАЦИИ
|
||
|
||
## 📊 РЕЗУЛЬТАТЫ ДИАГНОСТИКИ
|
||
|
||
### ✅ ТЕКУЩЕЕ СОСТОЯНИЕ СИСТЕМЫ:
|
||
|
||
**Данные в БД:**
|
||
- 1 активный API ключ Wildberries (длина 398 символов)
|
||
- Организация "Rennel" типа SELLER
|
||
- Ключ создан: 2025-09-17T15:19:35.423Z
|
||
- **ПРОБЛЕМА:** Хранится в открытом виде в поле `apiKey`
|
||
|
||
**UI Состояние:**
|
||
- ✅ Звездочки отображаются корректно: `••••••••••••••••••••`
|
||
- ✅ Логика переключения работает (редактирование очищает поле)
|
||
- ✅ Индикатор статуса показывает "API ключ настроен"
|
||
- ❌ **КРИТИЧНО:** handleSave НЕ СОХРАНЯЕТ API ключи
|
||
|
||
**Безопасность:**
|
||
- ❌ Ключи хранятся в открытом виде в БД
|
||
- ❌ ENCRYPTION_KEY отсутствует в .env
|
||
- ⚠️ Нужно проверить копирование звездочек
|
||
|
||
### 🎯 ЦЕЛИ РЕАЛИЗАЦИИ:
|
||
|
||
1. **Безопасность:** Зашифровать существующий API ключ
|
||
2. **Функциональность:** Исправить сохранение в handleSave
|
||
3. **UX:** Защитить от копирования звездочек
|
||
4. **Производительность:** Кэш расшифровки на 2 часа
|
||
|
||
---
|
||
|
||
## 🛡️ ПОЭТАПНЫЙ ПЛАН РЕАЛИЗАЦИИ
|
||
|
||
### 📋 ЭТАП 1: ТЕСТИРОВАНИЕ КОПИРОВАНИЯ (10 мин)
|
||
**Цель:** Убедиться что звездочки не раскрывают реальный ключ
|
||
|
||
**Задачи:**
|
||
- [ ] Запустить dev сервер
|
||
- [ ] Открыть /seller/settings в браузере
|
||
- [ ] Протестировать копирование поля с звездочками
|
||
- [ ] При необходимости добавить атрибуты блокировки
|
||
|
||
**Ожидаемый результат:** Звездочки нельзя скопировать как реальный ключ
|
||
|
||
**Критерии успеха:**
|
||
- ✅ При копировании получаются звездочки, а не настоящий ключ
|
||
- ✅ Невозможно выделить реальный ключ из поля
|
||
|
||
---
|
||
|
||
### 🔧 ЭТАП 2: ИСПРАВЛЕНИЕ СОХРАНЕНИЯ (30 мин)
|
||
**Цель:** handleSave начинает сохранять API ключи в БД
|
||
|
||
**Подготовка:**
|
||
```bash
|
||
# Backup текущего состояния
|
||
cp src/components/dashboard/user-settings.tsx src/components/dashboard/user-settings.tsx.backup
|
||
```
|
||
|
||
**Задачи:**
|
||
- [ ] Найти где обрезана функция handleSave (после строки 698)
|
||
- [ ] Добавить импорт ADD_MARKETPLACE_API_KEY в user-settings.tsx
|
||
- [ ] Добавить логику сохранения API ключей после сохранения профиля
|
||
- [ ] Протестировать сохранение через UI
|
||
|
||
**Код изменений:**
|
||
```typescript
|
||
// В импортах добавить:
|
||
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations'
|
||
|
||
// В handleSave добавить после сохранения профиля:
|
||
// Сохраняем API ключи маркетплейсов
|
||
if (formData.wildberriesApiKey && formData.wildberriesApiKey !== '••••••••••••••••••••') {
|
||
await apolloClient.mutate({
|
||
mutation: ADD_MARKETPLACE_API_KEY,
|
||
variables: {
|
||
input: {
|
||
marketplace: 'WILDBERRIES',
|
||
apiKey: formData.wildberriesApiKey,
|
||
validateOnly: false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
if (formData.ozonApiKey && formData.ozonApiKey !== '••••••••••••••••••••') {
|
||
await apolloClient.mutate({
|
||
mutation: ADD_MARKETPLACE_API_KEY,
|
||
variables: {
|
||
input: {
|
||
marketplace: 'OZON',
|
||
apiKey: formData.ozonApiKey,
|
||
clientId: formData.ozonClientId,
|
||
validateOnly: false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// Обновляем кэш с новыми API ключами
|
||
await apolloClient.refetchQueries({
|
||
include: [GET_ME]
|
||
})
|
||
```
|
||
|
||
**Тестирование:**
|
||
- [ ] Ввести новый WB ключ → должен сохраниться в БД
|
||
- [ ] Проверить что ключ виден в GraphQL ответе
|
||
- [ ] Проверить работу статистики с новым ключом
|
||
|
||
**Rollback план:** Восстановить из backup файла
|
||
|
||
---
|
||
|
||
### 🔐 ЭТАП 3: СОЗДАНИЕ КРИПТОСЕРВИСА (45 мин)
|
||
**Цель:** Безопасное шифрование с кэшем на 2 часа
|
||
|
||
**Подготовка:**
|
||
```bash
|
||
# Генерация мастер-ключа
|
||
openssl rand -hex 32
|
||
|
||
# Добавление в .env
|
||
echo "ENCRYPTION_KEY=сгенерированный_ключ" >> .env
|
||
```
|
||
|
||
**Задачи:**
|
||
- [ ] Создать `/src/services/crypto.service.ts`
|
||
- [ ] Реализовать AES-256-GCM шифрование
|
||
- [ ] Добавить кэш с TTL 2 часа
|
||
- [ ] Написать юнит-тесты
|
||
|
||
**Реализация CryptoService:**
|
||
```typescript
|
||
// src/services/crypto.service.ts
|
||
import crypto from 'crypto'
|
||
|
||
export class CryptoService {
|
||
private algorithm = 'aes-256-gcm'
|
||
private key: Buffer
|
||
|
||
// Кэш на 2 часа
|
||
private cache = new Map<string, { value: string; expires: number }>()
|
||
|
||
constructor() {
|
||
const masterKey = process.env.ENCRYPTION_KEY
|
||
if (!masterKey || masterKey.length !== 64) {
|
||
throw new Error('ENCRYPTION_KEY must be 64 hex characters')
|
||
}
|
||
this.key = Buffer.from(masterKey, 'hex')
|
||
}
|
||
|
||
encrypt(text: string): { encrypted: string; iv: string; tag: string } {
|
||
const iv = crypto.randomBytes(16)
|
||
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv)
|
||
|
||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||
encrypted += cipher.final('hex')
|
||
|
||
return {
|
||
encrypted,
|
||
iv: iv.toString('hex'),
|
||
tag: cipher.getAuthTag().toString('hex')
|
||
}
|
||
}
|
||
|
||
decrypt(encrypted: string, iv: string, tag: string): string {
|
||
// Проверяем кэш (2 часа TTL)
|
||
const cacheKey = `${encrypted}-${iv}`
|
||
const cached = this.cache.get(cacheKey)
|
||
if (cached && cached.expires > Date.now()) {
|
||
return cached.value
|
||
}
|
||
|
||
// Расшифровываем
|
||
const decipher = crypto.createDecipheriv(
|
||
this.algorithm,
|
||
this.key,
|
||
Buffer.from(iv, 'hex')
|
||
)
|
||
|
||
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
||
|
||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||
decrypted += decipher.final('utf8')
|
||
|
||
// Сохраняем в кэш на 2 часа
|
||
this.cache.set(cacheKey, {
|
||
value: decrypted,
|
||
expires: Date.now() + 2 * 60 * 60 * 1000 // 2 часа
|
||
})
|
||
|
||
// Очищаем старые записи
|
||
this.cleanCache()
|
||
|
||
return decrypted
|
||
}
|
||
|
||
private cleanCache() {
|
||
const now = Date.now()
|
||
for (const [key, item] of this.cache) {
|
||
if (item.expires < now) {
|
||
this.cache.delete(key)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Тестирование:**
|
||
- [ ] Шифрование/расшифровка работает корректно
|
||
- [ ] Кэш сохраняет данные на 2 часа
|
||
- [ ] Автоочистка удаляет старые записи
|
||
|
||
---
|
||
|
||
### 🗄️ ЭТАП 4: МИГРАЦИЯ БД (30 мин)
|
||
**Цель:** Добавить поля для зашифрованных ключей
|
||
|
||
**Задачи:**
|
||
- [ ] Создать Prisma миграцию с новыми полями
|
||
- [ ] Применить миграцию к БД
|
||
- [ ] Проверить совместимость
|
||
|
||
**Новая схема:**
|
||
```prisma
|
||
model ApiKey {
|
||
id String @id @default(cuid())
|
||
marketplace MarketplaceType
|
||
|
||
// Новые поля для шифрования
|
||
encryptedKey String? // Зашифрованный ключ
|
||
encryptionIv String? // Вектор инициализации
|
||
encryptionTag String? // Тег аутентификации
|
||
lastUsedAt DateTime? // Аудит использования
|
||
|
||
// Старое поле (временно для совместимости)
|
||
apiKey String // УДАЛИТЬ после миграции
|
||
|
||
clientId String?
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
validationData Json?
|
||
organizationId String
|
||
organization Organization @relation(fields: [organizationId], references: [id])
|
||
|
||
@@unique([organizationId, marketplace])
|
||
@@map("api_keys")
|
||
}
|
||
```
|
||
|
||
**Команды миграции:**
|
||
```bash
|
||
npx prisma migrate dev --name add_api_keys_encryption
|
||
npx prisma generate
|
||
```
|
||
|
||
**Тестирование:**
|
||
- [ ] Миграция применяется без ошибок
|
||
- [ ] Существующий ключ остается рабочим
|
||
- [ ] Новые поля готовы к использованию
|
||
|
||
**Rollback:** `npx prisma migrate reset` (данные не критичны)
|
||
|
||
---
|
||
|
||
### 🔄 ЭТАП 5: ОБНОВЛЕНИЕ РЕЗОЛВЕРОВ (45 мин)
|
||
**Цель:** Использовать шифрование в GraphQL
|
||
|
||
**Задачи:**
|
||
- [ ] Обновить addMarketplaceApiKey для шифрования новых ключей
|
||
- [ ] Обновить getWildberriesStatistics для расшифровки
|
||
- [ ] Добавить fallback на старые ключи
|
||
- [ ] Протестировать API
|
||
|
||
**Изменения в wildberries.ts:**
|
||
```typescript
|
||
import { CryptoService } from '../../../services/crypto.service'
|
||
|
||
const cryptoService = new CryptoService()
|
||
|
||
// В addMarketplaceApiKey:
|
||
if (existing) {
|
||
// Шифруем новый ключ
|
||
const encrypted = cryptoService.encrypt(apiKey)
|
||
|
||
await prisma.apiKey.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
encryptedKey: encrypted.encrypted,
|
||
encryptionIv: encrypted.iv,
|
||
encryptionTag: encrypted.tag,
|
||
apiKey: null, // Очищаем старое поле
|
||
isActive: true
|
||
}
|
||
})
|
||
}
|
||
|
||
// В getWildberriesStatistics:
|
||
let decryptedKey: string
|
||
|
||
if (apiKey.encryptedKey && apiKey.encryptionIv && apiKey.encryptionTag) {
|
||
// Новый зашифрованный ключ
|
||
decryptedKey = cryptoService.decrypt(
|
||
apiKey.encryptedKey,
|
||
apiKey.encryptionIv,
|
||
apiKey.encryptionTag
|
||
)
|
||
} else if (apiKey.apiKey) {
|
||
// Fallback на старый незашифрованный ключ
|
||
decryptedKey = apiKey.apiKey
|
||
} else {
|
||
return { success: false, message: 'API ключ не найден' }
|
||
}
|
||
```
|
||
|
||
**Тестирование:**
|
||
- [ ] Новые ключи шифруются при сохранении
|
||
- [ ] Старый ключ работает через fallback
|
||
- [ ] Статистика загружается корректно
|
||
- [ ] Кэш работает (повторные запросы быстрее)
|
||
|
||
---
|
||
|
||
### 📦 ЭТАП 6: МИГРАЦИЯ СУЩЕСТВУЮЩЕГО КЛЮЧА (15 мин)
|
||
**Цель:** Перешифровать единственный ключ в БД
|
||
|
||
**Задачи:**
|
||
- [ ] Создать скрипт миграции
|
||
- [ ] Выполнить перешифровку
|
||
- [ ] Убрать fallback логику
|
||
- [ ] Финальное тестирование
|
||
|
||
**Скрипт миграции:**
|
||
```javascript
|
||
// scripts/migrate-existing-api-keys.js
|
||
import { PrismaClient } from '@prisma/client'
|
||
import { CryptoService } from '../src/services/crypto.service.js'
|
||
|
||
const prisma = new PrismaClient()
|
||
const cryptoService = new CryptoService()
|
||
|
||
async function migrateApiKeys() {
|
||
const keys = await prisma.apiKey.findMany({
|
||
where: {
|
||
apiKey: { not: null },
|
||
encryptedKey: null
|
||
}
|
||
})
|
||
|
||
console.log(`🔄 Найдено ключей для миграции: ${keys.length}`)
|
||
|
||
for (const key of keys) {
|
||
if (!key.apiKey) continue
|
||
|
||
const encrypted = cryptoService.encrypt(key.apiKey)
|
||
|
||
await prisma.apiKey.update({
|
||
where: { id: key.id },
|
||
data: {
|
||
encryptedKey: encrypted.encrypted,
|
||
encryptionIv: encrypted.iv,
|
||
encryptionTag: encrypted.tag,
|
||
apiKey: null // Очищаем старое поле
|
||
}
|
||
})
|
||
|
||
console.log(`✅ Мигрирован ключ ${key.id} для ${key.marketplace}`)
|
||
}
|
||
|
||
console.log('🎉 Миграция завершена')
|
||
}
|
||
|
||
migrateApiKeys().catch(console.error)
|
||
```
|
||
|
||
**Выполнение:**
|
||
```bash
|
||
node scripts/migrate-existing-api-keys.js
|
||
```
|
||
|
||
**Тестирование:**
|
||
- [ ] Существующий ключ перешифрован
|
||
- [ ] Статистика продолжает работать
|
||
- [ ] В БД нет незашифрованных ключей
|
||
- [ ] Fallback больше не используется
|
||
|
||
---
|
||
|
||
## 🧪 ПЛАН ТЕСТИРОВАНИЯ
|
||
|
||
### После каждого этапа проверять:
|
||
|
||
**1. Основная функциональность:**
|
||
- [ ] Авторизация в кабинет селлера работает
|
||
- [ ] Настройки → API ключи открываются
|
||
- [ ] Статистика Wildberries загружается
|
||
|
||
**2. Сохранение ключей:**
|
||
- [ ] Ввод нового WB ключа сохраняется
|
||
- [ ] Настройки сохраняются без ошибок
|
||
- [ ] Ключ появляется в БД
|
||
|
||
**3. Безопасность:**
|
||
- [ ] Звездочки показываются вместо ключей
|
||
- [ ] Копирование не раскрывает реальные ключи
|
||
- [ ] GraphQL не возвращает расшифрованные ключи
|
||
- [ ] Ошибки не логируют реальные ключи
|
||
|
||
**4. Производительность:**
|
||
- [ ] Первый запрос статистики (расшифровка)
|
||
- [ ] Повторный запрос (из кэша) быстрее
|
||
- [ ] Память не растет при множественных запросах
|
||
|
||
---
|
||
|
||
## ⚠️ КРИТИЧЕСКИЕ МОМЕНТЫ
|
||
|
||
### 🚨 ТОЧКИ ОСТАНОВКИ:
|
||
- Статистика перестала загружаться
|
||
- Ошибки сохранения в настройках
|
||
- Потеря существующего API ключа
|
||
- Любые 500 ошибки в GraphQL
|
||
- Ошибки шифрования/расшифровки
|
||
|
||
### 🔄 ROLLBACK ПЛАНЫ:
|
||
|
||
**Этап 2:** Восстановить user-settings.tsx из backup
|
||
**Этап 3:** Не применять к продакшену
|
||
**Этап 4:** `npx prisma migrate reset`
|
||
**Этап 5:** Откатить изменения резолверов
|
||
**Этап 6:** Восстановить из backup БД
|
||
|
||
### 💾 BACKUP ФАЙЛЫ:
|
||
- ✅ `api_keys_backup_1758196936364.json` - данные БД
|
||
- ✅ `user-settings.tsx.backup` - будет создан на этапе 2
|
||
|
||
---
|
||
|
||
## 🎯 ОЖИДАЕМЫЙ РЕЗУЛЬТАТ
|
||
|
||
### После завершения всех этапов:
|
||
|
||
**✅ Безопасность:**
|
||
- API ключи зашифрованы в БД алгоритмом AES-256-GCM
|
||
- UI показывает только звездочки
|
||
- Копирование не раскрывает реальные ключи
|
||
- GraphQL не возвращает расшифрованные ключи
|
||
|
||
**✅ Функциональность:**
|
||
- Сохранение API ключей через UI работает
|
||
- Статистика Wildberries загружается корректно
|
||
- Существующий ключ мигрирован без потерь
|
||
|
||
**✅ Производительность:**
|
||
- Кэш расшифровки на 2 часа
|
||
- Автоочистка старых записей кэша
|
||
- Быстрые повторные запросы
|
||
|
||
**✅ Аудит:**
|
||
- Поле lastUsedAt отслеживает использование
|
||
- Логи не содержат реальных ключей
|
||
- Безопасное хранение в production
|
||
|
||
---
|
||
|
||
## 📋 ЧЕКЛИСТ ГОТОВНОСТИ
|
||
|
||
Перед началом убедиться:
|
||
- [ ] Проект запускается локально
|
||
- [ ] БД доступна и работает
|
||
- [ ] Есть доступ к кабинету селлера
|
||
- [ ] Backup данных создан
|
||
- [ ] ENCRYPTION_KEY сгенерирован
|
||
|
||
**План готов к исполнению! 🚀**
|
||
|
||
---
|
||
|
||
*Документ создан: 2025-09-18*
|
||
*Проект: SFERA Marketplace Platform*
|
||
*Статус: Готов к реализации* |