Ajout système d'authentification complet avec interface de connexion

Backend:
- auth.js: Système de tokens avec API keys UUID
- rateLimiter.js: Rate limiting multi-tiers (global, traduction, admin)
- logger.js: Logging des requêtes avec rotation automatique
- adminRoutes.js: Routes admin pour gestion des tokens
- server.js: Intégration de tous les middlewares de sécurité

Frontend:
- Interface de connexion modale élégante
- Stockage sécurisé API key dans localStorage
- Bouton déconnexion dans le header
- authFetch() wrapper pour toutes les requêtes protégées
- Protection automatique des endpoints de traduction

Sécurité:
- Token admin généré automatiquement au premier lancement
- Limites quotidiennes par token configurables
- Rate limiting pour prévenir les abus
- Logs détaillés de toutes les requêtes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
StillHammer 2025-12-02 12:01:01 +08:00
parent 5ad89885fc
commit 3cd73e6598
10 changed files with 888 additions and 9 deletions

View File

@ -0,0 +1,14 @@
# ConfluentTranslator Configuration
# Server
PORT=3000
# API Keys (LLM)
ANTHROPIC_API_KEY=sk-ant-your-key-here
OPENAI_API_KEY=sk-your-key-here
# Security (optionnel - utilisé pour JWT, peut être généré aléatoirement)
JWT_SECRET=changez-ce-secret-en-production
# Note: Les API keys pour le traducteur (authentication) sont gérées dans data/tokens.json
# Le token admin sera automatiquement créé au premier lancement et affiché dans les logs

View File

@ -0,0 +1,95 @@
const express = require('express');
const router = express.Router();
const { requireAdmin, createToken, listTokens, disableToken, enableToken, deleteToken, getGlobalStats } = require('./auth');
const { getLogs, getLogStats } = require('./logger');
const { adminLimiter } = require('./rateLimiter');
// Appliquer l'auth et rate limiting à toutes les routes admin
router.use(requireAdmin);
router.use(adminLimiter);
// Liste des tokens
router.get('/tokens', (req, res) => {
const tokens = listTokens();
res.json({ tokens });
});
// Créer un nouveau token
router.post('/tokens', (req, res) => {
const { name, role = 'user', dailyLimit = 100 } = req.body;
if (!name) {
return res.status(400).json({ error: 'Missing parameter: name' });
}
const token = createToken(name, role, dailyLimit);
res.json({
success: true,
token: {
...token,
apiKey: token.apiKey // Retourner la clé complète seulement à la création
}
});
});
// Désactiver un token
router.post('/tokens/:id/disable', (req, res) => {
const { id } = req.params;
const success = disableToken(id);
if (success) {
res.json({ success: true, message: 'Token disabled' });
} else {
res.status(404).json({ error: 'Token not found' });
}
});
// Réactiver un token
router.post('/tokens/:id/enable', (req, res) => {
const { id } = req.params;
const success = enableToken(id);
if (success) {
res.json({ success: true, message: 'Token enabled' });
} else {
res.status(404).json({ error: 'Token not found' });
}
});
// Supprimer un token
router.delete('/tokens/:id', (req, res) => {
const { id } = req.params;
const success = deleteToken(id);
if (success) {
res.json({ success: true, message: 'Token deleted' });
} else {
res.status(404).json({ error: 'Token not found or cannot be deleted' });
}
});
// Stats globales
router.get('/stats', (req, res) => {
const tokenStats = getGlobalStats();
const logStats = getLogStats();
res.json({
tokens: tokenStats,
logs: logStats
});
});
// Logs
router.get('/logs', (req, res) => {
const { limit = 100, user, path, statusCode } = req.query;
const filter = {};
if (user) filter.user = user;
if (path) filter.path = path;
if (statusCode) filter.statusCode = parseInt(statusCode);
const logs = getLogs(parseInt(limit), filter);
res.json({ logs });
});
module.exports = router;

206
ConfluentTranslator/auth.js Normal file
View File

@ -0,0 +1,206 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const path = require('path');
const TOKENS_FILE = path.join(__dirname, 'data', 'tokens.json');
const JWT_SECRET = process.env.JWT_SECRET || 'confluent-secret-key-change-in-production';
// Structure des tokens
let tokens = {};
function loadTokens() {
try {
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
if (fs.existsSync(TOKENS_FILE)) {
return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf8'));
}
} catch (error) {
console.error('Error loading tokens:', error);
}
// Default: créer un token admin si aucun token n'existe
const defaultTokens = {
admin: {
id: 'admin',
name: 'Admin',
role: 'admin',
apiKey: uuidv4(),
createdAt: new Date().toISOString(),
active: true,
requestsToday: 0,
dailyLimit: -1 // illimité
}
};
saveTokens(defaultTokens);
console.log('🔑 Token admin créé:', defaultTokens.admin.apiKey);
return defaultTokens;
}
function saveTokens() {
try {
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
} catch (error) {
console.error('Error saving tokens:', error);
}
}
// Middleware d'authentification
function authenticate(req, res, next) {
// Routes publiques (GET seulement)
const publicRoutes = ['/api/lexique', '/api/stats', '/lexique'];
if (req.method === 'GET' && publicRoutes.some(route => req.path.startsWith(route))) {
return next();
}
const apiKey = req.headers['x-api-key'] || req.query.apiKey;
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Chercher le token
const token = Object.values(tokens).find(t => t.apiKey === apiKey);
if (!token) {
return res.status(401).json({ error: 'Invalid API key' });
}
if (!token.active) {
return res.status(403).json({ error: 'Token disabled' });
}
// Vérifier la limite quotidienne
const today = new Date().toISOString().split('T')[0];
const tokenToday = token.lastUsed?.split('T')[0];
if (tokenToday !== today) {
token.requestsToday = 0;
}
if (token.dailyLimit > 0 && token.requestsToday >= token.dailyLimit) {
return res.status(429).json({ error: 'Daily limit reached' });
}
// Mettre à jour les stats
token.requestsToday++;
token.lastUsed = new Date().toISOString();
saveTokens();
// Ajouter les infos au req
req.user = {
id: token.id,
name: token.name,
role: token.role
};
next();
}
// Middleware admin uniquement
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
// Créer un nouveau token
function createToken(name, role = 'user', dailyLimit = 100) {
const id = uuidv4();
const apiKey = uuidv4();
tokens[id] = {
id,
name,
role,
apiKey,
createdAt: new Date().toISOString(),
active: true,
requestsToday: 0,
dailyLimit
};
saveTokens();
return tokens[id];
}
// Lister tous les tokens
function listTokens() {
return Object.values(tokens).map(t => ({
id: t.id,
name: t.name,
role: t.role,
apiKey: t.apiKey.substring(0, 8) + '...',
createdAt: t.createdAt,
active: t.active,
requestsToday: t.requestsToday,
dailyLimit: t.dailyLimit,
lastUsed: t.lastUsed
}));
}
// Désactiver un token
function disableToken(id) {
if (tokens[id]) {
tokens[id].active = false;
saveTokens();
return true;
}
return false;
}
// Réactiver un token
function enableToken(id) {
if (tokens[id]) {
tokens[id].active = true;
saveTokens();
return true;
}
return false;
}
// Supprimer un token
function deleteToken(id) {
if (id === 'admin') {
return false; // Ne pas supprimer l'admin
}
if (tokens[id]) {
delete tokens[id];
saveTokens();
return true;
}
return false;
}
// Stats globales
function getGlobalStats() {
const tokenList = Object.values(tokens);
return {
totalTokens: tokenList.length,
activeTokens: tokenList.filter(t => t.active).length,
totalRequestsToday: tokenList.reduce((sum, t) => sum + t.requestsToday, 0)
};
}
// Charger les tokens au démarrage
tokens = loadTokens();
module.exports = {
authenticate,
requireAdmin,
createToken,
listTokens,
disableToken,
enableToken,
deleteToken,
getGlobalStats,
loadTokens,
tokens
};

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,151 @@
const fs = require('fs');
const path = require('path');
const LOGS_DIR = path.join(__dirname, 'logs');
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
// Créer le dossier logs s'il n'existe pas
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
function getLogFile() {
const today = new Date().toISOString().split('T')[0];
return path.join(LOGS_DIR, `requests-${today}.log`);
}
function log(type, data) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
type,
...data
};
const logFile = getLogFile();
const logLine = JSON.stringify(logEntry) + '\n';
fs.appendFileSync(logFile, logLine);
// Rotation si le fichier devient trop gros
try {
const stats = fs.statSync(logFile);
if (stats.size > MAX_LOG_SIZE) {
const archiveName = logFile.replace('.log', `-${Date.now()}.log`);
fs.renameSync(logFile, archiveName);
}
} catch (error) {
console.error('Error rotating log file:', error);
}
}
// Middleware de logging
function requestLogger(req, res, next) {
const start = Date.now();
// Capturer la réponse
const originalSend = res.send;
res.send = function (data) {
const duration = Date.now() - start;
log('request', {
method: req.method,
path: req.path,
ip: req.ip || req.connection.remoteAddress,
user: req.user?.name || 'anonymous',
userId: req.user?.id || null,
statusCode: res.statusCode,
duration,
userAgent: req.headers['user-agent']
});
originalSend.apply(res, arguments);
};
next();
}
// Lire les logs
function getLogs(limit = 100, filter = {}) {
const logFile = getLogFile();
if (!fs.existsSync(logFile)) {
return [];
}
const logs = fs.readFileSync(logFile, 'utf8')
.split('\n')
.filter(line => line.trim())
.map(line => {
try {
return JSON.parse(line);
} catch (e) {
return null;
}
})
.filter(log => log !== null);
// Appliquer les filtres
let filtered = logs;
if (filter.user) {
filtered = filtered.filter(log => log.user === filter.user);
}
if (filter.path) {
filtered = filtered.filter(log => log.path && log.path.includes(filter.path));
}
if (filter.statusCode) {
filtered = filtered.filter(log => log.statusCode === filter.statusCode);
}
// Retourner les derniers logs
return filtered.slice(-limit).reverse();
}
// Stats des logs
function getLogStats() {
const logs = getLogs(1000);
const stats = {
totalRequests: logs.length,
byUser: {},
byPath: {},
byStatus: {},
avgDuration: 0,
errors: 0
};
let totalDuration = 0;
logs.forEach(log => {
// Par utilisateur
stats.byUser[log.user] = (stats.byUser[log.user] || 0) + 1;
// Par path
stats.byPath[log.path] = (stats.byPath[log.path] || 0) + 1;
// Par status
stats.byStatus[log.statusCode] = (stats.byStatus[log.statusCode] || 0) + 1;
// Durée
totalDuration += log.duration || 0;
// Erreurs
if (log.statusCode >= 400) {
stats.errors++;
}
});
stats.avgDuration = logs.length > 0 ? Math.round(totalDuration / logs.length) : 0;
return stats;
}
module.exports = {
log,
requestLogger,
getLogs,
getLogStats
};

View File

@ -9,9 +9,13 @@
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"bcryptjs": "^3.0.3",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"openai": "^4.20.1"
"express-rate-limit": "^8.2.1",
"jsonwebtoken": "^9.0.2",
"openai": "^4.20.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
@ -135,6 +139,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -196,6 +209,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -377,6 +396,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -507,6 +535,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -785,6 +831,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -853,6 +908,97 @@
"node": ">=16"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1238,7 +1384,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -1502,6 +1647,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -9,9 +9,13 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"bcryptjs": "^3.0.3",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"openai": "^4.20.1"
"express-rate-limit": "^8.2.1",
"jsonwebtoken": "^9.0.2",
"openai": "^4.20.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"

View File

@ -415,11 +415,110 @@
color: #666;
border-bottom-color: #e0e0e0;
}
/* Login overlay */
.login-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.login-overlay.hidden {
display: none;
}
.login-modal {
background: #2a2a2a;
border: 2px solid #4a9eff;
border-radius: 12px;
padding: 40px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(74, 158, 255, 0.3);
}
.login-modal h2 {
color: #4a9eff;
margin-bottom: 20px;
font-size: 1.5em;
text-align: center;
}
.login-modal p {
color: #b0b0b0;
margin-bottom: 20px;
line-height: 1.6;
}
.login-modal .form-group {
margin-bottom: 20px;
}
.login-modal input {
width: 100%;
padding: 12px;
font-size: 1em;
font-family: monospace;
}
.login-modal button {
width: 100%;
padding: 14px;
font-size: 1.1em;
}
.login-error {
color: #ff4a4a;
margin-top: 10px;
text-align: center;
display: none;
}
body.light-theme .login-modal {
background: #ffffff;
border-color: #2563eb;
box-shadow: 0 10px 40px rgba(37, 99, 235, 0.3);
}
body.light-theme .login-modal h2 {
color: #2563eb;
}
/* Logout button */
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.logout-btn {
background: #555;
padding: 8px 16px;
font-size: 0.9em;
font-weight: normal;
}
.logout-btn:hover {
background: #666;
}
</style>
</head>
<body>
<!-- Login overlay -->
<div id="login-overlay" class="login-overlay">
<div class="login-modal">
<h2>🔐 Connexion requise</h2>
<p>Entrez votre clé API pour accéder au traducteur Confluent.</p>
<div class="form-group">
<label>API Key</label>
<input type="password" id="login-api-key" placeholder="c32b04be-2e68-4e15-8362-...">
</div>
<button onclick="login()">Se connecter</button>
<div id="login-error" class="login-error">Clé API invalide</div>
</div>
</div>
<div class="container">
<h1>ConfluentTranslator</h1>
<div class="header-container">
<h1>ConfluentTranslator</h1>
<button class="logout-btn" onclick="logout()">Déconnexion</button>
</div>
<!-- Tabs -->
<div class="tabs">
@ -715,6 +814,106 @@
</div>
<script>
// === AUTHENTICATION SYSTEM ===
// Get API key from localStorage
const getApiKey = () => localStorage.getItem('confluentApiKey');
// Set API key to localStorage
const setApiKey = (key) => localStorage.setItem('confluentApiKey', key);
// Remove API key from localStorage
const clearApiKey = () => localStorage.removeItem('confluentApiKey');
// Check if user is authenticated
const checkAuth = () => {
const apiKey = getApiKey();
const overlay = document.getElementById('login-overlay');
if (!apiKey) {
// Show login overlay
overlay.classList.remove('hidden');
} else {
// Hide login overlay
overlay.classList.add('hidden');
}
return !!apiKey;
};
// Login function
const login = async () => {
const apiKey = document.getElementById('login-api-key').value.trim();
const errorDiv = document.getElementById('login-error');
if (!apiKey) {
errorDiv.textContent = 'Veuillez entrer une clé API';
errorDiv.style.display = 'block';
return;
}
// Test the API key with a simple request
try {
const response = await fetch('/api/stats', {
headers: {
'x-api-key': apiKey
}
});
if (response.ok) {
// Key is valid
setApiKey(apiKey);
checkAuth();
errorDiv.style.display = 'none';
document.getElementById('login-api-key').value = '';
} else if (response.status === 401 || response.status === 403) {
// Invalid key
errorDiv.textContent = 'Clé API invalide';
errorDiv.style.display = 'block';
} else {
// Other error
errorDiv.textContent = `Erreur: ${response.status}`;
errorDiv.style.display = 'block';
}
} catch (error) {
errorDiv.textContent = `Erreur de connexion: ${error.message}`;
errorDiv.style.display = 'block';
}
};
// Logout function
const logout = () => {
if (confirm('Êtes-vous sûr de vouloir vous déconnecter ?')) {
clearApiKey();
checkAuth();
}
};
// Add enter key support for login
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('login-api-key').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
login();
}
});
});
// Authenticated fetch wrapper
const authFetch = (url, options = {}) => {
const apiKey = getApiKey();
// Merge headers
const headers = {
...options.headers,
'x-api-key': apiKey
};
return fetch(url, {
...options,
headers
});
};
// Lexique data
let lexiqueData = null;
@ -1058,7 +1257,7 @@
};
try {
const response = await fetch('/translate', {
const response = await authFetch('/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@ -1159,7 +1358,7 @@
try {
// Step 1: Get raw translation to show details immediately
const rawResponse = await fetch('/api/translate/conf2fr', {
const rawResponse = await authFetch('/api/translate/conf2fr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -1213,7 +1412,7 @@
}
// Step 2: Get LLM refined translation
const llmResponse = await fetch('/api/translate/conf2fr/llm', {
const llmResponse = await authFetch('/api/translate/conf2fr/llm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -1245,6 +1444,7 @@
});
// Initialize
checkAuth(); // Check if user is logged in
loadSettings();
loadLexique();
updateSettingsIndicators();

View File

@ -0,0 +1,38 @@
const rateLimit = require('express-rate-limit');
// Rate limiter global par IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200, // max 200 requêtes par IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests from this IP, please try again later.' }
});
// Rate limiter pour les traductions (plus strict)
const translationLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // max 10 traductions par minute
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// Skip si l'utilisateur est admin
return req.user && req.user.role === 'admin';
},
message: { error: 'Too many translation requests. Please wait a moment.' }
});
// Rate limiter pour les endpoints sensibles (admin)
const adminLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 50,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many admin requests.' }
});
module.exports = {
globalLimiter,
translationLimiter,
adminLimiter
};

View File

@ -15,10 +15,18 @@ const { buildContextualPrompt, getBasePrompt, getPromptStats } = require('./prom
const { buildReverseIndex: buildConfluentIndex } = require('./reverseIndexBuilder');
const { translateConfluentToFrench, translateConfluentDetailed } = require('./confluentToFrench');
// Security modules
const { authenticate, requireAdmin, createToken, listTokens, disableToken, enableToken, deleteToken, getGlobalStats } = require('./auth');
const { globalLimiter, translationLimiter, adminLimiter } = require('./rateLimiter');
const { requestLogger, getLogs, getLogStats } = require('./logger');
const app = express();
const PORT = process.env.PORT || 3000;
// Middlewares
app.use(express.json());
app.use(requestLogger); // Log toutes les requêtes
app.use(globalLimiter); // Rate limiting global
app.use(express.static('public'));
// Load prompts
@ -319,7 +327,7 @@ app.post('/api/analyze/coverage', (req, res) => {
});
// Translation endpoint (NOUVEAU SYSTÈME CONTEXTUEL)
app.post('/translate', async (req, res) => {
app.post('/translate', authenticate, translationLimiter, async (req, res) => {
const { text, target, provider, model, temperature = 1.0, useLexique = true } = req.body;
if (!text || !target || !provider || !model) {
@ -640,7 +648,7 @@ app.post('/api/translate/conf2fr', (req, res) => {
});
// NEW: Confluent → French with LLM refinement
app.post('/api/translate/conf2fr/llm', async (req, res) => {
app.post('/api/translate/conf2fr/llm', authenticate, translationLimiter, async (req, res) => {
const { text, variant = 'ancien', provider = 'anthropic', model = 'claude-sonnet-4-20250514' } = req.body;
if (!text) {
@ -719,6 +727,10 @@ app.post('/api/translate/conf2fr/llm', async (req, res) => {
}
});
// Admin routes
const adminRoutes = require('./adminRoutes');
app.use('/api/admin', authenticate, adminRoutes);
app.listen(PORT, () => {
console.log(`ConfluentTranslator running on http://localhost:${PORT}`);
console.log(`Loaded: ${lexiques.ancien?.meta?.total_entries || 0} ancien entries, ${lexiques.proto?.meta?.total_entries || 0} proto entries`);