diff --git a/docs/API-RAPPROCHEMENT.md b/docs/API-RAPPROCHEMENT.md
index e0073f4..4191359 100644
--- a/docs/API-RAPPROCHEMENT.md
+++ b/docs/API-RAPPROCHEMENT.md
@@ -8,8 +8,15 @@ Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
## Activation et authentification
-Définir une ou plusieurs clés (séparées par des virgules) dans l'environnement,
-puis redémarrer :
+**Depuis LogiFlow (recommandé)** : *Paramètres > API externe* (admin). On y
+crée une clé par outil (nommée, ex. « n8n comptabilité »), affichée une seule
+fois à sa création, avec sa date de dernière utilisation ; une clé se révoque
+d'un clic, sans redémarrage. Seule l'empreinte SHA-256 de la clé est stockée
+(table `external_api_keys`). L'onglet s'ouvre directement par
+`/utilities?tab=api`.
+
+**Par l'environnement (optionnel)** : des clés peuvent aussi être définies,
+séparées par des virgules, puis le serveur redémarré :
```
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
@@ -21,7 +28,8 @@ Générer une clé :
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
-Variable vide ou absente : l'API répond `503` (désactivée).
+Aucune clé active (ni dans Paramètres, ni dans l'environnement) : l'API répond
+`503` (désactivée).
Chaque requête envoie la clé dans l'un de ces en-têtes :
diff --git a/init.sql b/init.sql
index ec03f3f..68c7d6d 100644
--- a/init.sql
+++ b/init.sql
@@ -438,6 +438,18 @@ CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
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);
+-- Clés de l'API externe de rapprochement (empreinte SHA-256, jamais la clé en clair)
+CREATE TABLE IF NOT EXISTS "external_api_keys" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "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
+);
+
-- ============================================================================
-- FOREIGN KEY CONSTRAINTS
-- ============================================================================
diff --git a/server/externalApi.ts b/server/externalApi.ts
index b11829b..2edf2c7 100644
--- a/server/externalApi.ts
+++ b/server/externalApi.ts
@@ -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
".
//
// 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" });
+ }
+ });
+}
diff --git a/server/externalApiKeys.ts b/server/externalApiKeys.ts
new file mode 100644
index 0000000..4ee1b2a
--- /dev/null
+++ b/server/externalApiKeys.ts
@@ -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;
+
+// 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 {
+ 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 {
+ 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 {
+ if (!hasDatabase) return memoryKeys.filter((k) => !k.revokedAt).length;
+ const [row] = await db
+ .select({ count: sql`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 {
+ 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;
+}
diff --git a/server/migrations.production.ts b/server/migrations.production.ts
index 12543d9..24b4b44 100644
--- a/server/migrations.production.ts
+++ b/server/migrations.production.ts
@@ -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.
diff --git a/server/migrations.ts b/server/migrations.ts
index 0c623a8..e788a74 100644
--- a/server/migrations.ts
+++ b/server/migrations.ts
@@ -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
+ );
+ `
}
];
diff --git a/server/routes.ts b/server/routes.ts
index b72214b..0a1c724 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -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 {
// 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 {
diff --git a/shared/schema.ts b/shared/schema.ts
index b4d4071..97da862 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -1023,6 +1023,21 @@ export const insertWebhookBapConfigSchema = createInsertSchema(webhookBapConfig)
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
export type InsertWebhookBapConfig = z.infer;
+// Clés de l'API externe de rapprochement (docs/API-RAPPROCHEMENT.md), créées
+// depuis Paramètres. Seule l'empreinte SHA-256 de la clé est stockée.
+export const externalApiKeys = pgTable("external_api_keys", {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 100 }).notNull(),
+ keyPrefix: varchar("key_prefix", { length: 16 }).notNull(), // début de la clé, pour la reconnaître
+ keyHash: varchar("key_hash", { length: 64 }).notNull().unique(),
+ createdBy: varchar("created_by"),
+ createdAt: timestamp("created_at").defaultNow(),
+ lastUsedAt: timestamp("last_used_at"),
+ revokedAt: timestamp("revoked_at"),
+});
+
+export type ExternalApiKey = typeof externalApiKeys.$inferSelect;
+
// Supplier mail logs
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
id: true,