456 lines
14 KiB
PHP
456 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* Gestionnaire de clés API pour le chatbot
|
|
* Chiffrement AES-256-CBC des clés API
|
|
*
|
|
* @author WebVal
|
|
* @date 2026-01-04
|
|
*/
|
|
|
|
class APIKeyManager {
|
|
|
|
private static $encryption_key = null;
|
|
private static $db = null;
|
|
|
|
/**
|
|
* Initialiser la connexion BDD
|
|
*/
|
|
private static function init() {
|
|
if (self::$db === null) {
|
|
self::$db = Database::getInstance();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupérer la clé de chiffrement
|
|
* Ordre de priorité : ENV > Fichier > Génération
|
|
*/
|
|
private static function getEncryptionKey() {
|
|
if (self::$encryption_key !== null) {
|
|
return self::$encryption_key;
|
|
}
|
|
|
|
// 1. Variable d'environnement
|
|
$key = getenv('WEBVAL_API_ENCRYPTION_KEY');
|
|
|
|
// 2. Fichier externe (hors webroot)
|
|
if (!$key && file_exists('/etc/webval/encryption.key')) {
|
|
$key = file_get_contents('/etc/webval/encryption.key');
|
|
}
|
|
|
|
// 3. Fichier dans config (fallback)
|
|
$keyFile = __DIR__ . '/../config/.encryption_key';
|
|
if (!$key && file_exists($keyFile)) {
|
|
$key = file_get_contents($keyFile);
|
|
}
|
|
|
|
// 4. Générer une nouvelle clé si aucune n'existe
|
|
if (!$key) {
|
|
$key = bin2hex(random_bytes(32));
|
|
|
|
// Essayer de sauvegarder
|
|
if (is_writable(__DIR__ . '/../config/')) {
|
|
file_put_contents($keyFile, $key);
|
|
chmod($keyFile, 0600);
|
|
}
|
|
|
|
error_log("⚠️ Nouvelle clé de chiffrement générée. Sauvegarde recommandée.");
|
|
}
|
|
|
|
self::$encryption_key = $key;
|
|
return $key;
|
|
}
|
|
|
|
/**
|
|
* Chiffrer une clé API
|
|
*
|
|
* @param string $plaintext Clé API en clair
|
|
* @return string Clé chiffrée (base64)
|
|
*/
|
|
public static function encrypt($plaintext) {
|
|
if (empty($plaintext)) {
|
|
return null;
|
|
}
|
|
|
|
$key = hash('sha256', self::getEncryptionKey(), true);
|
|
$iv = random_bytes(16);
|
|
|
|
$ciphertext = openssl_encrypt(
|
|
$plaintext,
|
|
'AES-256-CBC',
|
|
$key,
|
|
OPENSSL_RAW_DATA,
|
|
$iv
|
|
);
|
|
|
|
if ($ciphertext === false) {
|
|
throw new Exception("Erreur de chiffrement");
|
|
}
|
|
|
|
// Format: IV (16 bytes) + Ciphertext
|
|
return base64_encode($iv . $ciphertext);
|
|
}
|
|
|
|
/**
|
|
* Déchiffrer une clé API
|
|
*
|
|
* @param string $encrypted Clé chiffrée (base64)
|
|
* @return string|null Clé API en clair
|
|
*/
|
|
public static function decrypt($encrypted) {
|
|
if (empty($encrypted)) {
|
|
return null;
|
|
}
|
|
|
|
$key = hash('sha256', self::getEncryptionKey(), true);
|
|
$data = base64_decode($encrypted);
|
|
|
|
if ($data === false || strlen($data) < 16) {
|
|
throw new Exception("Données chiffrées invalides");
|
|
}
|
|
|
|
$iv = substr($data, 0, 16);
|
|
$ciphertext = substr($data, 16);
|
|
|
|
$plaintext = openssl_decrypt(
|
|
$ciphertext,
|
|
'AES-256-CBC',
|
|
$key,
|
|
OPENSSL_RAW_DATA,
|
|
$iv
|
|
);
|
|
|
|
if ($plaintext === false) {
|
|
throw new Exception("Erreur de déchiffrement");
|
|
}
|
|
|
|
return $plaintext;
|
|
}
|
|
|
|
/**
|
|
* Sauvegarder une clé API
|
|
*
|
|
* @param int $id_enseignant ID de l'enseignant
|
|
* @param string $provider Provider (gemini, mistral, etc.)
|
|
* @param string $api_key Clé API en clair
|
|
* @param string $model_default Modèle par défaut
|
|
* @return bool Succès
|
|
*/
|
|
public static function saveKey($id_enseignant, $provider, $api_key, $model_default = null) {
|
|
self::init();
|
|
|
|
// Chiffrer la clé
|
|
$encrypted = self::encrypt($api_key);
|
|
|
|
// Upsert (INSERT ... ON DUPLICATE KEY UPDATE)
|
|
$query = "INSERT INTO api_keys
|
|
(id_enseignant, provider, api_key_encrypted, model_default, is_active, quota_reset_date)
|
|
VALUES (?, ?, ?, ?, TRUE, DATE_ADD(CURDATE(), INTERVAL 1 MONTH))
|
|
ON DUPLICATE KEY UPDATE
|
|
api_key_encrypted = VALUES(api_key_encrypted),
|
|
model_default = VALUES(model_default),
|
|
is_active = TRUE,
|
|
updated_at = CURRENT_TIMESTAMP";
|
|
|
|
return self::$db->query($query, [
|
|
$id_enseignant,
|
|
$provider,
|
|
$encrypted,
|
|
$model_default
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Récupérer une clé API déchiffrée
|
|
*
|
|
* @param int $id_enseignant ID de l'enseignant
|
|
* @param string $provider Provider
|
|
* @return array|null ['api_key' => '...', 'model_default' => '...']
|
|
*/
|
|
public static function getKey($id_enseignant, $provider) {
|
|
self::init();
|
|
|
|
$result = self::$db->fetchOne(
|
|
"SELECT api_key_encrypted, model_default, is_active
|
|
FROM api_keys
|
|
WHERE id_enseignant = ? AND provider = ?",
|
|
[$id_enseignant, $provider]
|
|
);
|
|
|
|
if (!$result || !$result['is_active']) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'api_key' => self::decrypt($result['api_key_encrypted']),
|
|
'model_default' => $result['model_default']
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Supprimer une clé API
|
|
*
|
|
* @param int $id_enseignant ID de l'enseignant
|
|
* @param string $provider Provider
|
|
* @return bool Succès
|
|
*/
|
|
public static function deleteKey($id_enseignant, $provider) {
|
|
self::init();
|
|
|
|
return self::$db->query(
|
|
"DELETE FROM api_keys WHERE id_enseignant = ? AND provider = ?",
|
|
[$id_enseignant, $provider]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lister toutes les clés d'un enseignant
|
|
*
|
|
* @param int $id_enseignant ID de l'enseignant
|
|
* @return array Liste des clés (sans les clés déchiffrées)
|
|
*/
|
|
public static function listKeys($id_enseignant) {
|
|
self::init();
|
|
|
|
return self::$db->fetchAll(
|
|
"SELECT
|
|
id,
|
|
provider,
|
|
model_default,
|
|
is_active,
|
|
quota_max,
|
|
quota_used,
|
|
quota_reset_date,
|
|
last_used,
|
|
created_at
|
|
FROM api_keys
|
|
WHERE id_enseignant = ?
|
|
ORDER BY provider",
|
|
[$id_enseignant]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Incrémenter le quota utilisé
|
|
*
|
|
* @param int $id_enseignant ID de l'enseignant
|
|
* @param string $provider Provider
|
|
* @param int $tokens_used Nombre de tokens utilisés
|
|
*/
|
|
public static function incrementQuota($id_enseignant, $provider, $tokens_used = 1) {
|
|
self::init();
|
|
|
|
// Reset quota si date dépassée
|
|
self::$db->query(
|
|
"UPDATE api_keys
|
|
SET quota_used = 0, quota_reset_date = DATE_ADD(CURDATE(), INTERVAL 1 MONTH)
|
|
WHERE id_enseignant = ? AND provider = ? AND quota_reset_date < CURDATE()",
|
|
[$id_enseignant, $provider]
|
|
);
|
|
|
|
// Incrémenter
|
|
return self::$db->query(
|
|
"UPDATE api_keys
|
|
SET quota_used = quota_used + ?, last_used = NOW()
|
|
WHERE id_enseignant = ? AND provider = ?",
|
|
[$tokens_used, $id_enseignant, $provider]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Vérifier si le quota est dépassé
|
|
*
|
|
* @param int $id_enseignant ID de l'enseignant
|
|
* @param string $provider Provider
|
|
* @return bool True si quota OK
|
|
*/
|
|
public static function checkQuota($id_enseignant, $provider) {
|
|
self::init();
|
|
|
|
$result = self::$db->fetchOne(
|
|
"SELECT quota_max, quota_used FROM api_keys
|
|
WHERE id_enseignant = ? AND provider = ?",
|
|
[$id_enseignant, $provider]
|
|
);
|
|
|
|
if (!$result || $result['quota_max'] === null) {
|
|
return true; // Pas de limite
|
|
}
|
|
|
|
return $result['quota_used'] < $result['quota_max'];
|
|
}
|
|
|
|
/**
|
|
* Tester une connexion API
|
|
*
|
|
* @param string $provider Provider à tester
|
|
* @param string $api_key Clé API
|
|
* @param string $model Modèle à tester
|
|
* @return array ['success' => bool, 'message' => string, 'latency_ms' => int]
|
|
*/
|
|
public static function testConnection($provider, $api_key, $model = null) {
|
|
$start = microtime(true);
|
|
|
|
try {
|
|
switch ($provider) {
|
|
case 'gemini':
|
|
$result = self::testGemini($api_key, $model ?? 'gemini-2.0-flash-exp');
|
|
break;
|
|
|
|
case 'mistral':
|
|
$result = self::testMistral($api_key, $model ?? 'ministral-3b-latest');
|
|
break;
|
|
|
|
case 'openai':
|
|
$result = self::testOpenAI($api_key, $model ?? 'gpt-4o-mini');
|
|
break;
|
|
|
|
case 'ollama':
|
|
$result = self::testOllama($model ?? 'ministral-3:3b');
|
|
break;
|
|
|
|
default:
|
|
return ['success' => false, 'message' => 'Provider inconnu'];
|
|
}
|
|
|
|
$latency = round((microtime(true) - $start) * 1000);
|
|
$result['latency_ms'] = $latency;
|
|
|
|
return $result;
|
|
|
|
} catch (Exception $e) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Erreur: ' . $e->getMessage(),
|
|
'latency_ms' => 0
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tester connexion Gemini
|
|
*/
|
|
private static function testGemini($api_key, $model) {
|
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$api_key}";
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_POSTFIELDS => json_encode([
|
|
'contents' => [['parts' => [['text' => 'Test']]]]
|
|
])
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($http_code === 200) {
|
|
return ['success' => true, 'message' => 'Connexion Gemini réussie'];
|
|
} elseif ($http_code === 400) {
|
|
return ['success' => false, 'message' => 'Clé API invalide'];
|
|
} elseif ($http_code === 429) {
|
|
return ['success' => false, 'message' => 'Quota dépassé'];
|
|
} else {
|
|
return ['success' => false, 'message' => "Erreur HTTP $http_code"];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tester connexion Mistral
|
|
*/
|
|
private static function testMistral($api_key, $model) {
|
|
$ch = curl_init('https://api.mistral.ai/v1/chat/completions');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $api_key
|
|
],
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_POSTFIELDS => json_encode([
|
|
'model' => $model,
|
|
'messages' => [['role' => 'user', 'content' => 'Test']],
|
|
'max_tokens' => 10
|
|
])
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($http_code === 200) {
|
|
return ['success' => true, 'message' => 'Connexion Mistral réussie'];
|
|
} elseif ($http_code === 401) {
|
|
return ['success' => false, 'message' => 'Clé API invalide'];
|
|
} else {
|
|
return ['success' => false, 'message' => "Erreur HTTP $http_code"];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tester connexion OpenAI
|
|
*/
|
|
private static function testOpenAI($api_key, $model) {
|
|
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $api_key
|
|
],
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_POSTFIELDS => json_encode([
|
|
'model' => $model,
|
|
'messages' => [['role' => 'user', 'content' => 'Test']],
|
|
'max_tokens' => 10
|
|
])
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($http_code === 200) {
|
|
return ['success' => true, 'message' => 'Connexion OpenAI réussie'];
|
|
} elseif ($http_code === 401) {
|
|
return ['success' => false, 'message' => 'Clé API invalide'];
|
|
} else {
|
|
return ['success' => false, 'message' => "Erreur HTTP $http_code"];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tester connexion Ollama (local)
|
|
*/
|
|
private static function testOllama($model) {
|
|
$ch = curl_init('http://localhost:11434/api/generate');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_TIMEOUT => 5,
|
|
CURLOPT_POSTFIELDS => json_encode([
|
|
'model' => $model,
|
|
'prompt' => 'Test',
|
|
'stream' => false
|
|
])
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($http_code === 200) {
|
|
return ['success' => true, 'message' => 'Connexion Ollama réussie'];
|
|
} else {
|
|
return ['success' => false, 'message' => 'Ollama non disponible'];
|
|
}
|
|
}
|
|
}
|