mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
feat: add permanent API keys for users
- Add api_key column to users table with auto-migration - Modify auth middleware to accept both JWT (24h) and API keys (permanent) - Add endpoints: GET/POST/DELETE /api/users/:id/api-key - Update admin panel with API key management (generate, copy, regenerate, revoke) - Update API documentation modal to show permanent API key usage API keys use the format: nf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX Use header: X-API-Key: <your-key>
This commit is contained in:
5 files changed
+325
-27
No files matched your search
@@ -97,10 +97,21 @@ async function initDatabase() {
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
api_key VARCHAR(64) UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: ajouter la colonne api_key si elle n'existe pas
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'api_key') THEN
|
||||
ALTER TABLE users ADD COLUMN api_key VARCHAR(64) UNIQUE;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Table notes
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
|
||||
+34
-4
@@ -1,6 +1,7 @@
|
||||
// Middleware d'authentification JWT
|
||||
// Middleware d'authentification JWT et API Key
|
||||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('../config/logger');
|
||||
const { getOne } = require('../config/database');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_production_please_use_strong_secret';
|
||||
|
||||
@@ -9,14 +10,42 @@ if (JWT_SECRET === 'change_me_in_production_please_use_strong_secret' && process
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware pour vérifier le token JWT
|
||||
* Middleware pour vérifier le token JWT ou la clé API
|
||||
* Supporte:
|
||||
* - Header "Authorization: Bearer TOKEN" (JWT, expire après 24h)
|
||||
* - Header "X-API-Key: API_KEY" (clé permanente)
|
||||
*/
|
||||
function authenticateToken(req, res, next) {
|
||||
async function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
// Option 1: Clé API permanente (prioritaire)
|
||||
if (apiKey) {
|
||||
try {
|
||||
const user = await getOne(
|
||||
'SELECT id, username, is_admin FROM users WHERE api_key = $1',
|
||||
[apiKey]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`Tentative d'accès avec clé API invalide`);
|
||||
return res.status(403).json({ error: 'Clé API invalide' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
req.authType = 'api_key';
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la vérification de la clé API:', error);
|
||||
return res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
}
|
||||
|
||||
// Option 2: Token JWT
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Format: "Bearer TOKEN"
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Token d\'authentification manquant' });
|
||||
return res.status(401).json({ error: 'Token d\'authentification ou clé API manquant' });
|
||||
}
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
@@ -27,6 +56,7 @@ function authenticateToken(req, res, next) {
|
||||
|
||||
// Ajouter les informations utilisateur à la requête
|
||||
req.user = user;
|
||||
req.authType = 'jwt';
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
+95
-3
@@ -2,32 +2,124 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Génère une clé API sécurisée
|
||||
* Format: nf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (48 caractères hex)
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return 'nf_' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
// Toutes les routes nécessitent authentification et droits admin
|
||||
router.use(authenticateToken);
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/users
|
||||
* Liste tous les utilisateurs
|
||||
* Liste tous les utilisateurs (avec indication si clé API existe)
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const users = await getAll(
|
||||
'SELECT id, username, is_admin, created_at FROM users ORDER BY created_at DESC'
|
||||
'SELECT id, username, is_admin, api_key, created_at FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(users);
|
||||
// Ne pas exposer la clé complète, juste indiquer si elle existe
|
||||
const usersWithApiKeyStatus = users.map(u => ({
|
||||
...u,
|
||||
has_api_key: !!u.api_key,
|
||||
api_key: undefined // Ne pas envoyer la clé dans la liste
|
||||
}));
|
||||
res.json(usersWithApiKeyStatus);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des utilisateurs:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/users/:id/api-key
|
||||
* Récupérer la clé API d'un utilisateur (génère si n'existe pas)
|
||||
*/
|
||||
router.get('/:id/api-key', async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
const user = await getOne('SELECT id, username, api_key FROM users WHERE id = $1', [userId]);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Utilisateur non trouvé' });
|
||||
}
|
||||
|
||||
// Si pas de clé API, en générer une
|
||||
if (!user.api_key) {
|
||||
const newApiKey = generateApiKey();
|
||||
await runQuery('UPDATE users SET api_key = $1 WHERE id = $2', [newApiKey, userId]);
|
||||
logger.info(`[API KEY] Clé API générée pour ${user.username} (ID: ${userId})`);
|
||||
return res.json({ api_key: newApiKey, generated: true });
|
||||
}
|
||||
|
||||
res.json({ api_key: user.api_key, generated: false });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération de la clé API:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/users/:id/api-key/regenerate
|
||||
* Régénérer la clé API d'un utilisateur
|
||||
*/
|
||||
router.post('/:id/api-key/regenerate', async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
const user = await getOne('SELECT id, username FROM users WHERE id = $1', [userId]);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Utilisateur non trouvé' });
|
||||
}
|
||||
|
||||
const newApiKey = generateApiKey();
|
||||
await runQuery('UPDATE users SET api_key = $1 WHERE id = $2', [newApiKey, userId]);
|
||||
|
||||
logger.info(`[API KEY] Clé API régénérée pour ${user.username} (ID: ${userId})`);
|
||||
|
||||
res.json({ api_key: newApiKey, message: 'Clé API régénérée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la régénération de la clé API:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/users/:id/api-key
|
||||
* Révoquer la clé API d'un utilisateur
|
||||
*/
|
||||
router.delete('/:id/api-key', async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
const user = await getOne('SELECT id, username FROM users WHERE id = $1', [userId]);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Utilisateur non trouvé' });
|
||||
}
|
||||
|
||||
await runQuery('UPDATE users SET api_key = NULL WHERE id = $1', [userId]);
|
||||
|
||||
logger.info(`[API KEY] Clé API révoquée pour ${user.username} (ID: ${userId})`);
|
||||
|
||||
res.json({ message: 'Clé API révoquée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la révocation de la clé API:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/users
|
||||
* Créer un nouvel utilisateur
|
||||
|
||||
+120
-20
@@ -194,7 +194,7 @@ const Index = () => {
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||
const [showStatsDashboard, setShowStatsDashboard] = useState(false);
|
||||
const [showPomodoroModal, setShowPomodoroModal] = useState(false);
|
||||
const [apiInfoModal, setApiInfoModal] = useState<{ open: boolean; user?: UserType }>({ open: false });
|
||||
const [apiInfoModal, setApiInfoModal] = useState<{ open: boolean; user?: UserType; apiKey?: string; loading?: boolean }>({ open: false });
|
||||
|
||||
// Pomodoro timer states
|
||||
const [pomodoroRunning, setPomodoroRunning] = useState(false);
|
||||
@@ -2262,7 +2262,15 @@ const Index = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setApiInfoModal({ open: true, user: u })}
|
||||
onClick={async () => {
|
||||
setApiInfoModal({ open: true, user: u, loading: true });
|
||||
const result = await AdminService.getApiKey(u.id);
|
||||
if (result) {
|
||||
setApiInfoModal({ open: true, user: u, apiKey: result.api_key, loading: false });
|
||||
} else {
|
||||
setApiInfoModal({ open: true, user: u, loading: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
API
|
||||
@@ -2615,25 +2623,116 @@ const Index = () => {
|
||||
Informations API - {apiInfoModal.user?.username}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Détails de connexion et exemples d'utilisation de l'API
|
||||
Clé API permanente et exemples d'utilisation
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Connexion */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">1. Authentification</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Obtenez un token JWT en vous connectant avec vos identifiants :
|
||||
{/* Clé API permanente */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Clé API (permanente)
|
||||
</h3>
|
||||
|
||||
{apiInfoModal.loading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
) : apiInfoModal.apiKey ? (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mb-2 font-medium">
|
||||
Clé API active - Ne la partagez jamais !
|
||||
</p>
|
||||
<code className="block p-2 bg-white dark:bg-gray-800 border rounded text-sm font-mono break-all select-all">
|
||||
{apiInfoModal.apiKey}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiInfoModal.apiKey || '');
|
||||
showSuccess("Clé API copiée !");
|
||||
}}
|
||||
>
|
||||
Copier
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (apiInfoModal.user) {
|
||||
const result = await AdminService.regenerateApiKey(apiInfoModal.user.id);
|
||||
if (result) {
|
||||
setApiInfoModal({ ...apiInfoModal, apiKey: result.api_key });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Régénérer
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (apiInfoModal.user) {
|
||||
const success = await AdminService.revokeApiKey(apiInfoModal.user.id);
|
||||
if (success) {
|
||||
setApiInfoModal({ ...apiInfoModal, apiKey: undefined });
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Révoquer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Aucune clé API. Cliquez pour en générer une.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (apiInfoModal.user) {
|
||||
setApiInfoModal({ ...apiInfoModal, loading: true });
|
||||
const result = await AdminService.getApiKey(apiInfoModal.user.id);
|
||||
if (result) {
|
||||
setApiInfoModal({ ...apiInfoModal, apiKey: result.api_key, loading: false });
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Générer une clé API
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted p-3 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<p className="text-xs text-muted-foreground mb-2 font-sans">Utilisation avec curl :</p>
|
||||
<pre>{`curl ${window.location.origin}/api/notes \\
|
||||
-H "X-API-Key: ${apiInfoModal.apiKey || 'VOTRE_CLE_API'}"`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alternative JWT */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h3 className="font-semibold text-sm text-muted-foreground">Alternative : Token JWT (expire après 24h)</h3>
|
||||
<div className="bg-muted p-3 rounded-lg font-mono text-xs overflow-x-auto">
|
||||
<pre>{`curl -X POST ${window.location.origin}/api/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username": "${apiInfoModal.user?.username}", "password": "VOTRE_MOT_DE_PASSE"}'`}</pre>
|
||||
-d '{"username": "${apiInfoModal.user?.username}", "password": "MOT_DE_PASSE"}'`}</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Réponse : <code className="bg-muted px-1 rounded">{`{"token": "eyJhbG...", "user": {...}}`}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Endpoints */}
|
||||
@@ -2709,10 +2808,10 @@ const Index = () => {
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">3. Exemple complet (curl)</h3>
|
||||
<div className="bg-muted p-3 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre>{`# Créer une note avec tâches
|
||||
<pre>{`# Créer une note avec tâches (clé API permanente)
|
||||
curl -X POST ${window.location.origin}/api/notes/full \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer VOTRE_TOKEN" \\
|
||||
-H "X-API-Key: ${apiInfoModal.apiKey || 'VOTRE_CLE_API'}" \\
|
||||
-d '{
|
||||
"title": "Liste de courses",
|
||||
"content": "Pour le weekend",
|
||||
@@ -2727,13 +2826,14 @@ curl -X POST ${window.location.origin}/api/notes/full \\
|
||||
</div>
|
||||
|
||||
{/* Info importante */}
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200 mb-1">
|
||||
⚠️ Important
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-sm font-semibold text-green-900 dark:text-green-200 mb-1">
|
||||
✓ Clé API permanente
|
||||
</p>
|
||||
<ul className="text-xs text-yellow-800 dark:text-yellow-300 space-y-1">
|
||||
<li>• Le token expire après <strong>24 heures</strong></li>
|
||||
<li>• Utilisez le header <code className="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">Authorization: Bearer TOKEN</code></li>
|
||||
<ul className="text-xs text-green-800 dark:text-green-300 space-y-1">
|
||||
<li>• La clé API <strong>n'expire jamais</strong> (sauf si révoquée)</li>
|
||||
<li>• Utilisez le header <code className="bg-green-100 dark:bg-green-800 px-1 rounded">X-API-Key: VOTRE_CLE</code></li>
|
||||
<li>• Gardez votre clé secrète - ne la partagez jamais</li>
|
||||
<li>• Les données sont isolées par utilisateur</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
has_api_key?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -134,6 +135,70 @@ class AdminService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer la clé API d'un utilisateur (génère si n'existe pas)
|
||||
*/
|
||||
async getApiKey(userId: number): Promise<{ api_key: string; generated: boolean } | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/api-key`, {
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur lors de la récupération de la clé API");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "Erreur serveur");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Régénérer la clé API d'un utilisateur
|
||||
*/
|
||||
async regenerateApiKey(userId: number): Promise<{ api_key: string } | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/api-key/regenerate`, {
|
||||
method: "POST",
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur lors de la régénération de la clé API");
|
||||
}
|
||||
|
||||
showSuccess("Clé API régénérée avec succès");
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "Erreur serveur");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Révoquer la clé API d'un utilisateur
|
||||
*/
|
||||
async revokeApiKey(userId: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/api-key`, {
|
||||
method: "DELETE",
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur lors de la révocation de la clé API");
|
||||
}
|
||||
|
||||
showSuccess("Clé API révoquée avec succès");
|
||||
return true;
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "Erreur serveur");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Créer une instance unique
|
||||
|
||||
Reference in new issue
Block a user