mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
feat(api) : gérer les clés de l'API externe depuis Paramètres (#570)
Nouvel onglet Paramètres > API externe (admin) : état de l'API, adresse, création d'une clé nommée par outil (affichée une seule fois), liste avec dernière utilisation, révocation immédiate. - table external_api_keys (empreinte SHA-256 uniquement), créée par migrations.production.ts et init.sql - l'API accepte les clés de Paramètres et toujours celles de EXTERNAL_API_KEYS ; 503 seulement si aucune clé active - routes /api/external-api/keys (session + admin) - documentation et .env.example mis à jour Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
This commit is contained in:
1 parent
b964e137bf
commit
6760f43e87
11 files changed
+576
-19
No files matched your search
+75
-13
@@ -6,8 +6,10 @@
|
||||
// compris déjà validée, pour les fournisseurs en rapprochement automatique).
|
||||
//
|
||||
// Authentification par clé d'API, indépendante des sessions du webUI :
|
||||
// - variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||
// séparées par des virgules) ; vide => API désactivée ;
|
||||
// - clés créées par un admin dans Paramètres > API externe (server/externalApiKeys.ts) ;
|
||||
// - et/ou variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||
// séparées par des virgules) ;
|
||||
// - aucune clé active => API désactivée ;
|
||||
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
||||
//
|
||||
// Documentation : docs/API-RAPPROCHEMENT.md
|
||||
@@ -18,6 +20,14 @@ import { z } from "zod";
|
||||
import { storage } from "./storage";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { invoiceVerificationService } from "./invoiceVerification";
|
||||
import {
|
||||
countActiveApiKeys,
|
||||
countEnvApiKeys,
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
verifyStoredApiKey,
|
||||
} from "./externalApiKeys";
|
||||
|
||||
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
||||
|
||||
@@ -50,22 +60,27 @@ function extractKey(req: Request): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const keys = configuredKeyDigests();
|
||||
if (keys.length === 0) {
|
||||
return res.status(503).json({ error: "API externe désactivée (EXTERNAL_API_KEYS non défini)" });
|
||||
}
|
||||
async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const provided = extractKey(req);
|
||||
if (provided) {
|
||||
// Clés de l'environnement : comparaison à temps constant sur les empreintes
|
||||
const providedDigest = digest(provided);
|
||||
if (configuredKeyDigests().some((k) => timingSafeEqual(k, providedDigest))) return next();
|
||||
// Clés créées dans Paramètres (recherche par empreinte)
|
||||
if (await verifyStoredApiKey(provided)) return next();
|
||||
}
|
||||
|
||||
const provided = extractKey(req);
|
||||
// Comparaison à temps constant sur les empreintes (longueur fixe)
|
||||
const providedDigest = provided ? digest(provided) : null;
|
||||
const valid = providedDigest !== null && keys.some((k) => timingSafeEqual(k, providedDigest));
|
||||
if (countEnvApiKeys() === 0 && (await countActiveApiKeys()) === 0) {
|
||||
return res.status(503).json({ error: "API externe désactivée : aucune clé d'API active (Paramètres > API externe)" });
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
||||
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] vérification de la clé:", error);
|
||||
return res.status(500).json({ error: "Erreur lors de la vérification de la clé d'API" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
||||
@@ -351,3 +366,50 @@ export function registerExternalApi(app: Express) {
|
||||
res.status(404).json({ error: "Route inconnue" });
|
||||
});
|
||||
}
|
||||
|
||||
// Gestion des clés depuis Paramètres > API externe : session webUI, admin
|
||||
// uniquement. À enregistrer après la mise en place de l'authentification.
|
||||
export function registerExternalApiKeyAdminRoutes(app: Express, requireSessionAdmin: Array<(req: any, res: any, next: any) => void>) {
|
||||
const createKeySchema = z.object({ name: z.string().trim().min(1, "Nom requis").max(100) });
|
||||
|
||||
app.get("/api/external-api/keys", ...requireSessionAdmin, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
basePath: EXTERNAL_API_PREFIX,
|
||||
envKeyCount: countEnvApiKeys(),
|
||||
keys: await listApiKeys(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] list keys:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des clés d'API" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/external-api/keys", ...requireSessionAdmin, async (req: any, res) => {
|
||||
const parsed = createKeySchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message || "Nom invalide" });
|
||||
}
|
||||
try {
|
||||
const created = await createApiKey(parsed.data.name, req.user?.id ?? null);
|
||||
console.log(`🔑 [EXT-API] Clé « ${parsed.data.name} » créée par ${req.user?.username ?? "?"}`);
|
||||
res.status(201).json(created);
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] create key:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la création de la clé d'API" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/external-api/keys/:id", ...requireSessionAdmin, async (req: any, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
try {
|
||||
if (!(await revokeApiKey(id))) return res.status(404).json({ error: "Clé introuvable ou déjà révoquée" });
|
||||
console.log(`🔑 [EXT-API] Clé #${id} révoquée par ${req.user?.username ?? "?"}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] revoke key:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la révocation de la clé d'API" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Clés de l'API externe de rapprochement créées depuis Paramètres > API externe.
|
||||
//
|
||||
// La clé n'est montrée qu'une fois, à sa création : seule son empreinte SHA-256
|
||||
// est stockée (table external_api_keys), avec un préfixe lisible pour la
|
||||
// reconnaître dans la liste. Une clé révoquée est conservée pour l'historique.
|
||||
// Les clés de la variable d'environnement EXTERNAL_API_KEYS restent acceptées.
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { externalApiKeys, type ExternalApiKey } from "@shared/schema";
|
||||
|
||||
const KEY_PREFIX = "lf_";
|
||||
const DISPLAY_PREFIX_LENGTH = 10; // "lf_" + 7 caractères
|
||||
// last_used_at n'est réécrit qu'au plus une fois par minute et par clé
|
||||
const LAST_USED_THROTTLE_MS = 60_000;
|
||||
|
||||
export type PublicApiKey = Omit<ExternalApiKey, "keyHash">;
|
||||
|
||||
// Sans base (développement), les clés sont gardées en mémoire
|
||||
const hasDatabase = !!process.env.DATABASE_URL;
|
||||
const memoryKeys: ExternalApiKey[] = [];
|
||||
let memoryNextId = 1;
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function toPublic({ keyHash: _keyHash, ...key }: ExternalApiKey): PublicApiKey {
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function listApiKeys(): Promise<PublicApiKey[]> {
|
||||
if (!hasDatabase) return [...memoryKeys].reverse().map(toPublic);
|
||||
const rows: ExternalApiKey[] = await db.select().from(externalApiKeys).orderBy(desc(externalApiKeys.createdAt));
|
||||
return rows.map(toPublic);
|
||||
}
|
||||
|
||||
// Crée une clé et la renvoie en clair : c'est la seule fois où elle est lisible
|
||||
export async function createApiKey(name: string, createdBy: string | null): Promise<{ key: string; apiKey: PublicApiKey }> {
|
||||
const key = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
|
||||
const values = {
|
||||
name,
|
||||
keyPrefix: key.slice(0, DISPLAY_PREFIX_LENGTH),
|
||||
keyHash: hashApiKey(key),
|
||||
createdBy,
|
||||
};
|
||||
|
||||
if (!hasDatabase) {
|
||||
const row: ExternalApiKey = { id: memoryNextId++, ...values, createdAt: new Date(), lastUsedAt: null, revokedAt: null };
|
||||
memoryKeys.push(row);
|
||||
return { key, apiKey: toPublic(row) };
|
||||
}
|
||||
|
||||
const [row]: ExternalApiKey[] = await db.insert(externalApiKeys).values(values).returning();
|
||||
return { key, apiKey: toPublic(row) };
|
||||
}
|
||||
|
||||
// Révoque une clé active ; false si elle n'existe pas ou l'était déjà
|
||||
export async function revokeApiKey(id: number): Promise<boolean> {
|
||||
if (!hasDatabase) {
|
||||
const row = memoryKeys.find((k) => k.id === id && !k.revokedAt);
|
||||
if (!row) return false;
|
||||
row.revokedAt = new Date();
|
||||
return true;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(externalApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(externalApiKeys.id, id), isNull(externalApiKeys.revokedAt)))
|
||||
.returning({ id: externalApiKeys.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function countActiveApiKeys(): Promise<number> {
|
||||
if (!hasDatabase) return memoryKeys.filter((k) => !k.revokedAt).length;
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(externalApiKeys)
|
||||
.where(isNull(externalApiKeys.revokedAt));
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
// Vérifie une clé reçue ; met à jour sa date de dernière utilisation
|
||||
export async function verifyStoredApiKey(key: string): Promise<boolean> {
|
||||
const keyHash = hashApiKey(key);
|
||||
|
||||
if (!hasDatabase) {
|
||||
const row = memoryKeys.find((k) => k.keyHash === keyHash && !k.revokedAt);
|
||||
if (row) row.lastUsedAt = new Date();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
const [row]: ExternalApiKey[] = await db
|
||||
.select()
|
||||
.from(externalApiKeys)
|
||||
.where(and(eq(externalApiKeys.keyHash, keyHash), isNull(externalApiKeys.revokedAt)))
|
||||
.limit(1);
|
||||
if (!row) return false;
|
||||
|
||||
const lastUsed = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
|
||||
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
|
||||
// Sans attendre : une erreur d'écriture ne doit pas refuser la requête
|
||||
db.update(externalApiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(externalApiKeys.id, row.id))
|
||||
.catch((error: any) => console.error("❌ [EXT-API] last_used_at:", error?.message));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Nombre de clés fournies par la variable d'environnement EXTERNAL_API_KEYS
|
||||
export function countEnvApiKeys(): number {
|
||||
return (process.env.EXTERNAL_API_KEYS || "").split(",").filter((k) => k.trim().length > 0).length;
|
||||
}
|
||||
@@ -378,6 +378,22 @@ export async function runProductionMigrations() {
|
||||
`);
|
||||
console.log('✅ MIGRATION: supplier_mail_logs table is present');
|
||||
|
||||
// Clés de l'API externe de rapprochement, gérées depuis Paramètres
|
||||
console.log('🔄 MIGRATION: Ensuring external_api_keys table...');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_by VARCHAR,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP
|
||||
);
|
||||
`);
|
||||
console.log('✅ MIGRATION: external_api_keys table is present');
|
||||
|
||||
// Chiffrement au repos des secrets encore stockés en clair
|
||||
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
|
||||
// les valeurs déjà au format enc:v1: sont ignorées.
|
||||
|
||||
@@ -106,6 +106,21 @@ WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
`
|
||||
},
|
||||
{
|
||||
filename: '20261010000000_create_external_api_keys.sql',
|
||||
content: `
|
||||
CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_by VARCHAR,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP
|
||||
);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ import { setupLocalAuth, requireAuth } from "./localAuth";
|
||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||
import { stripSmtpPassword } from "./sanitize";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { registerExternalApi } from "./externalApi";
|
||||
import { registerExternalApi, registerExternalApiKeyAdminRoutes } from "./externalApi";
|
||||
|
||||
// Corps de requête sans les champs secrets : pour les logs uniquement
|
||||
function redactBody(body: any): any {
|
||||
@@ -126,6 +126,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
// Auth middleware
|
||||
await setupAuth(app);
|
||||
|
||||
// Clés de l'API externe (Paramètres > API externe), admin uniquement
|
||||
registerExternalApiKeyAdminRoutes(app, [isAuthenticated, requireAdmin]);
|
||||
|
||||
// Routes pour configuration webhook BAP
|
||||
app.get('/api/webhook-bap-config', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user