
- API_KEYS_IMPLEMENTATION_PLAN.md - план реализации системы API ключей - API_KEYS_SECURITY_PLAN.md - план безопасности API ключей - API_KEYS_SIMPLE_PLAN.md - упрощенный план API ключей - DEBUG_SELLER_STATISTICS.md - отладка статистики селлеров - FIX_API_KEYS_SAVING.md - исправление сохранения API ключей 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
17 KiB
17 KiB
🔐 ДЕТАЛЬНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ API КЛЮЧЕЙ
📋 ТРЕБОВАНИЯ
- Множественные ключи - организация может иметь несколько API ключей для разных приложений/магазинов
- Шифрование - все ключи хранятся зашифрованными
- Функциональность - ключи должны работать без задержек на расшифровку
- Безопасный UI - никогда не показывать реальные ключи в интерфейсе
- Аудит - логирование всех операций с ключами
🏗️ АРХИТЕКТУРА
1. НОВАЯ СХЕМА БД
// Основная таблица API ключей
model ApiKey {
id String @id @default(cuid())
name String // Название ключа (например: "Основной магазин WB")
marketplace MarketplaceType
encryptedKey String // Зашифрованный ключ
encryptionIv String // Вектор инициализации
encryptionTag String // Тег аутентификации
keyFingerprint String // Последние 4 символа для идентификации
isActive Boolean @default(true)
isPrimary Boolean @default(false) // Основной ключ для маркетплейса
// Метаданные
clientId String? // Для Ozon
validationData Json? // Результаты последней валидации
lastValidatedAt DateTime?
lastUsedAt DateTime?
usageCount Int @default(0)
// Связи
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
createdById String
createdBy User @relation("ApiKeyCreator", fields: [createdById], references: [id])
// Аудит
auditLogs ApiKeyAudit[]
// Временные метки
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime? // Опциональный срок действия
@@index([organizationId, marketplace])
@@index([keyFingerprint])
@@map("api_keys")
}
// Аудит операций с ключами
model ApiKeyAudit {
id String @id @default(cuid())
action ApiKeyAction
performedAt DateTime @default(now())
performedBy String
user User @relation(fields: [performedBy], references: [id])
apiKeyId String
apiKey ApiKey @relation(fields: [apiKeyId], references: [id])
// Контекст операции
ipAddress String?
userAgent String?
metadata Json? // Дополнительные данные
@@index([apiKeyId])
@@index([performedBy])
@@map("api_key_audit")
}
enum ApiKeyAction {
CREATED
UPDATED
VALIDATED
ACTIVATED
DEACTIVATED
DELETED
USED_FOR_SYNC
VALIDATION_FAILED
}
2. СЕРВИС ШИФРОВАНИЯ
// src/services/encryption-service.ts
import crypto from 'crypto'
import { LRUCache } from 'lru-cache'
export class EncryptionService {
private algorithm = 'aes-256-gcm'
private keyDerivationSalt: Buffer
// Кэш для расшифрованных ключей (время жизни: 5 минут)
private decryptedCache = new LRUCache<string, string>({
max: 100,
ttl: 1000 * 60 * 5, // 5 минут
dispose: (value) => {
// Очистка из памяти
if (value) {
crypto.randomFillSync(Buffer.from(value))
}
},
})
constructor() {
const masterKey = process.env.ENCRYPTION_MASTER_KEY
if (!masterKey) {
throw new Error('ENCRYPTION_MASTER_KEY not set')
}
// Генерация ключа шифрования из мастер-ключа
this.keyDerivationSalt = Buffer.from(process.env.ENCRYPTION_SALT || 'sfera-api-keys-salt')
}
private getDerivedKey(): Buffer {
const masterKey = process.env.ENCRYPTION_MASTER_KEY!
return crypto.pbkdf2Sync(masterKey, this.keyDerivationSalt, 10000, 32, 'sha256')
}
encrypt(plainText: string): EncryptedData {
const key = this.getDerivedKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.algorithm, key, iv)
let encrypted = cipher.update(plainText, 'utf8', 'hex')
encrypted += cipher.final('hex')
return {
encrypted,
iv: iv.toString('hex'),
tag: cipher.getAuthTag().toString('hex'),
}
}
decrypt(encryptedData: EncryptedData): string {
// Проверяем кэш
const cacheKey = `${encryptedData.encrypted}-${encryptedData.iv}`
const cached = this.decryptedCache.get(cacheKey)
if (cached) {
return cached
}
const key = this.getDerivedKey()
const decipher = crypto.createDecipheriv(this.algorithm, key, Buffer.from(encryptedData.iv, 'hex'))
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'))
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// Сохраняем в кэш
this.decryptedCache.set(cacheKey, decrypted)
return decrypted
}
// Получение отпечатка ключа (последние 4 символа)
getFingerprint(apiKey: string): string {
return apiKey.slice(-4)
}
// Генерация маски для UI
generateMask(fingerprint: string): string {
return `••••••••••••${fingerprint}`
}
}
interface EncryptedData {
encrypted: string
iv: string
tag: string
}
3. ОБНОВЛЕННЫЙ СЕРВИС API КЛЮЧЕЙ
// src/services/api-key-service.ts
export class ApiKeyService {
constructor(
private prisma: PrismaClient,
private encryption: EncryptionService,
private marketplace: MarketplaceService,
) {}
async createApiKey(data: CreateApiKeyInput, userId: string): Promise<ApiKey> {
// 1. Валидация ключа
const validation = await this.marketplace.validateApiKey(data.marketplace, data.apiKey, data.clientId)
if (!validation.isValid) {
throw new Error(`Недействительный API ключ: ${validation.error}`)
}
// 2. Шифрование
const encrypted = this.encryption.encrypt(data.apiKey)
const fingerprint = this.encryption.getFingerprint(data.apiKey)
// 3. Сохранение
return this.prisma.$transaction(async (tx) => {
// Если это первый ключ для маркетплейса - делаем его основным
const existingCount = await tx.apiKey.count({
where: {
organizationId: data.organizationId,
marketplace: data.marketplace,
isActive: true,
},
})
const apiKey = await tx.apiKey.create({
data: {
name: data.name,
marketplace: data.marketplace,
encryptedKey: encrypted.encrypted,
encryptionIv: encrypted.iv,
encryptionTag: encrypted.tag,
keyFingerprint: fingerprint,
clientId: data.clientId,
isPrimary: existingCount === 0,
organizationId: data.organizationId,
createdById: userId,
validationData: validation.data,
lastValidatedAt: new Date(),
},
})
// 4. Аудит
await tx.apiKeyAudit.create({
data: {
action: 'CREATED',
apiKeyId: apiKey.id,
performedBy: userId,
metadata: {
name: data.name,
marketplace: data.marketplace,
},
},
})
return apiKey
})
}
async getDecryptedKey(apiKeyId: string, userId: string): Promise<string> {
const apiKey = await this.prisma.apiKey.findUnique({
where: { id: apiKeyId },
})
if (!apiKey) {
throw new Error('API ключ не найден')
}
// Аудит использования
await this.prisma.apiKeyAudit.create({
data: {
action: 'USED_FOR_SYNC',
apiKeyId: apiKey.id,
performedBy: userId,
},
})
// Обновляем статистику
await this.prisma.apiKey.update({
where: { id: apiKeyId },
data: {
lastUsedAt: new Date(),
usageCount: { increment: 1 },
},
})
// Расшифровка
return this.encryption.decrypt({
encrypted: apiKey.encryptedKey,
iv: apiKey.encryptionIv,
tag: apiKey.encryptionTag,
})
}
async rotateKey(apiKeyId: string, newApiKey: string, userId: string): Promise<void> {
// Валидация нового ключа
const apiKey = await this.prisma.apiKey.findUnique({
where: { id: apiKeyId },
})
if (!apiKey) throw new Error('API ключ не найден')
const validation = await this.marketplace.validateApiKey(apiKey.marketplace, newApiKey, apiKey.clientId)
if (!validation.isValid) {
throw new Error('Новый ключ недействителен')
}
// Шифрование нового ключа
const encrypted = this.encryption.encrypt(newApiKey)
const fingerprint = this.encryption.getFingerprint(newApiKey)
// Обновление
await this.prisma.$transaction(async (tx) => {
await tx.apiKey.update({
where: { id: apiKeyId },
data: {
encryptedKey: encrypted.encrypted,
encryptionIv: encrypted.iv,
encryptionTag: encrypted.tag,
keyFingerprint: fingerprint,
lastValidatedAt: new Date(),
},
})
await tx.apiKeyAudit.create({
data: {
action: 'UPDATED',
apiKeyId: apiKeyId,
performedBy: userId,
metadata: { reason: 'key_rotation' },
},
})
})
// Очистка кэша
this.encryption.clearCache()
}
}
4. ОБНОВЛЕННЫЙ UI КОМПОНЕНТ
// src/components/settings/ApiKeysTab.tsx
export function ApiKeysTab() {
const [apiKeys, setApiKeys] = useState<ApiKeyDisplay[]>([])
const [showAddModal, setShowAddModal] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
return (
<div className="space-y-6">
{/* Заголовок с кнопкой добавления */}
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-white">API Ключи</h3>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Добавить ключ
</Button>
</div>
{/* Список ключей */}
<div className="space-y-4">
{apiKeys.map((apiKey) => (
<Card key={apiKey.id} className="glass-card p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h4 className="font-medium text-white">{apiKey.name}</h4>
<Badge variant={apiKey.isPrimary ? 'default' : 'secondary'}>
{apiKey.isPrimary ? 'Основной' : 'Дополнительный'}
</Badge>
<Badge variant={apiKey.isActive ? 'success' : 'destructive'}>
{apiKey.isActive ? 'Активен' : 'Неактивен'}
</Badge>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-white/60">
Маркетплейс: {apiKey.marketplace}
</p>
<p className="text-sm text-white/60">
Ключ: {apiKey.maskedKey}
</p>
{apiKey.lastUsedAt && (
<p className="text-sm text-white/60">
Использован: {formatDate(apiKey.lastUsedAt)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{editingKey === apiKey.id ? (
<RotateKeyForm
apiKeyId={apiKey.id}
onSuccess={() => setEditingKey(null)}
onCancel={() => setEditingKey(null)}
/>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditingKey(apiKey.id)}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleKeyStatus(apiKey.id)}
>
{apiKey.isActive ?
<ToggleLeft className="h-4 w-4" /> :
<ToggleRight className="h-4 w-4" />
}
</Button>
</>
)}
</div>
</div>
</Card>
))}
</div>
{/* Модальное окно добавления */}
{showAddModal && (
<AddApiKeyModal
onClose={() => setShowAddModal(false)}
onSuccess={() => {
setShowAddModal(false)
refetchApiKeys()
}}
/>
)}
</div>
)
}
5. GRAPHQL СХЕМА
type ApiKey {
id: ID!
name: String!
marketplace: MarketplaceType!
maskedKey: String! # Только маска ••••••••1234
isActive: Boolean!
isPrimary: Boolean!
lastUsedAt: DateTime
lastValidatedAt: DateTime
createdAt: DateTime!
usageCount: Int!
}
input CreateApiKeyInput {
name: String!
marketplace: MarketplaceType!
apiKey: String!
clientId: String # Для Ozon
}
type Mutation {
createApiKey(input: CreateApiKeyInput!): ApiKeyResponse!
updateApiKey(id: ID!, newKey: String!): ApiKeyResponse!
toggleApiKeyStatus(id: ID!): ApiKeyResponse!
deleteApiKey(id: ID!): Boolean!
setPrimaryApiKey(id: ID!): ApiKeyResponse!
}
type Query {
myApiKeys(marketplace: MarketplaceType): [ApiKey!]!
validateApiKey(marketplace: MarketplaceType!, apiKey: String!): ValidationResponse!
}
📋 ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ
ЭТАП 1: Подготовка (1 день)
- ✅ Создать миграцию БД с новой схемой
- ✅ Настроить переменные окружения для шифрования
- ✅ Создать EncryptionService
- ✅ Написать тесты для шифрования
ЭТАП 2: Бэкенд (2-3 дня)
- ✅ Создать ApiKeyService
- ✅ Обновить GraphQL схему
- ✅ Создать резолверы для API ключей
- ✅ Добавить middleware для аудита
- ✅ Миграция существующих ключей
ЭТАП 3: Интеграция (2 дня)
- ✅ Обновить WildberriesService для работы с новой системой
- ✅ Обновить статистику селлеров
- ✅ Добавить автоматическую ротацию ключей
- ✅ Настроить мониторинг использования
ЭТАП 4: UI (2 дня)
- ✅ Создать компонент ApiKeysTab
- ✅ Добавить модалки для добавления/редактирования
- ✅ Интегрировать в user-settings
- ✅ Добавить уведомления
ЭТАП 5: Тестирование (1 день)
- ✅ E2E тесты
- ✅ Тесты безопасности
- ✅ Нагрузочное тестирование
🔒 МЕРЫ БЕЗОПАСНОСТИ
- Никогда не логировать расшифрованные ключи
- Автоматическая очистка кэша каждые 5 минут
- Принудительная ротация ключей каждые 90 дней
- Алерты при подозрительной активности
- Ограничение количества ключей на организацию
⚡ ОПТИМИЗАЦИИ
- LRU кэш для расшифрованных ключей (5 минут TTL)
- Индексы БД по organizationId и marketplace
- Batch загрузка ключей для статистики
- Фоновая валидация ключей раз в сутки