From 1a64a73d5aed4a61aba139e6e5a9bc747843a4b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:52:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20g=C3=A9rer=20les=20cl=C3=A9s=20de?= =?UTF-8?q?=20l'API=20externe=20depuis=20Param=C3=A8tres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les clés de l'API de rapprochement n'étaient configurables que par la variable d'environnement EXTERNAL_API_KEYS. Nouvel onglet Paramètres > API externe (admin) : - état de l'API, adresse à copier, rappel des principales routes - création d'une clé nommée par outil, affichée une seule fois - liste des clés avec préfixe, date de création et de dernière utilisation ; révocation immédiate, sans redémarrage Côté serveur : - table external_api_keys (empreinte SHA-256 uniquement), créée par migrations.production.ts et init.sql - server/externalApiKeys.ts : création, liste, révocation, vérification (repli en mémoire sans base) - 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 Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ --- .env.example | 3 +- client/src/pages/ExternalApiSettings.tsx | 291 +++++++++++++++++++++++ client/src/pages/Utilities.tsx | 20 +- docs/API-RAPPROCHEMENT.md | 14 +- init.sql | 12 + server/externalApi.ts | 88 ++++++- server/externalApiKeys.ts | 116 +++++++++ server/migrations.production.ts | 16 ++ server/migrations.ts | 15 ++ server/routes.ts | 5 +- shared/schema.ts | 15 ++ 11 files changed, 576 insertions(+), 19 deletions(-) create mode 100644 client/src/pages/ExternalApiSettings.tsx create mode 100644 server/externalApiKeys.ts diff --git a/.env.example b/.env.example index a4fc949..ec107f1 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ ENCRYPTION_KEY= EMERGENCY_SECRET= # API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md). -# Une ou plusieurs clés séparées par des virgules ; vide = API désactivée. +# Les clés se créent de préférence dans Paramètres > API externe. Optionnel : +# une ou plusieurs clés supplémentaires ici, séparées par des virgules. # Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" EXTERNAL_API_KEYS= diff --git a/client/src/pages/ExternalApiSettings.tsx b/client/src/pages/ExternalApiSettings.tsx new file mode 100644 index 0000000..a440037 --- /dev/null +++ b/client/src/pages/ExternalApiSettings.tsx @@ -0,0 +1,291 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import { AlertTriangle, Check, Copy, KeyRound, Plug, Plus, Trash2 } from "lucide-react"; + +interface ApiKey { + id: number; + name: string; + keyPrefix: string; + createdAt: string | null; + lastUsedAt: string | null; + revokedAt: string | null; +} + +interface ApiKeysResponse { + basePath: string; + envKeyCount: number; + keys: ApiKey[]; +} + +const QUERY_KEY = ["/api/external-api/keys"]; + +function formatDateTime(value: string | null): string { + if (!value) return "—"; + return new Date(value).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }); +} + +// Message d'erreur lisible depuis « 400: {"error":"..."} » renvoyé par apiRequest +function errorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + const json = raw.slice(raw.indexOf(":") + 1).trim(); + try { + return JSON.parse(json).error || raw; + } catch { + return raw; + } +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + const { toast } = useToast(); + + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ title: "Copie impossible", description: "Sélectionnez le texte et copiez-le à la main.", variant: "destructive" }); + } + }; + + return ( + + ); +} + +export default function ExternalApiSettings() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [newKeyName, setNewKeyName] = useState(""); + const [createdKey, setCreatedKey] = useState<{ name: string; key: string } | null>(null); + const [keyToRevoke, setKeyToRevoke] = useState(null); + + const { data, isLoading, error } = useQuery({ queryKey: QUERY_KEY }); + + const createMutation = useMutation({ + mutationFn: (name: string) => apiRequest("/api/external-api/keys", "POST", { name }), + onSuccess: (result: { key: string; apiKey: ApiKey }) => { + setCreatedKey({ name: result.apiKey.name, key: result.key }); + setNewKeyName(""); + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + onError: (err) => toast({ title: "Création impossible", description: errorMessage(err), variant: "destructive" }), + }); + + const revokeMutation = useMutation({ + mutationFn: (id: number) => apiRequest(`/api/external-api/keys/${id}`, "DELETE"), + onSuccess: () => { + toast({ title: "Clé révoquée", description: "Les outils qui l'utilisaient n'ont plus accès à l'API." }); + setKeyToRevoke(null); + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + onError: (err) => toast({ title: "Révocation impossible", description: errorMessage(err), variant: "destructive" }), + }); + + const apiUrl = `${window.location.origin}${data?.basePath ?? "/api/ext/v1"}`; + const activeKeys = data?.keys.filter((k) => !k.revokedAt) ?? []; + const revokedKeys = data?.keys.filter((k) => k.revokedAt) ?? []; + const apiEnabled = activeKeys.length > 0 || (data?.envKeyCount ?? 0) > 0; + + const handleCreate = (event: React.FormEvent) => { + event.preventDefault(); + const name = newKeyName.trim(); + if (name) createMutation.mutate(name); + }; + + return ( +
+ {/* Présentation et adresse */} + + + + + API externe de rapprochement + + + Permet à un outil externe (n8n, comptabilité, script…) de lire les fournisseurs et les numéros de BL + des livraisons, puis d'écrire le numéro de facture, les montants et l'échéance. + + + +
+ État : + {isLoading ? ( + Chargement… + ) : apiEnabled ? ( + Active + ) : ( + Désactivée : aucune clé active + )} +
+ +
+ +
+ + +
+

+ La clé s'envoie dans l'en-tête X-API-Key{" "} + (ou Authorization: Bearer …). +

+
+ +
+

Principales routes

+
    +
  • GET /stores — magasins
  • +
  • GET /suppliers — fournisseurs
  • +
  • GET /deliveries?supplierId=…&hasInvoice=false — livraisons et n° de BL
  • +
  • PATCH /deliveries/:id — n° de facture, montants HT/TTC, échéance
  • +
+

Documentation complète : docs/API-RAPPROCHEMENT.md

+
+
+
+ + {/* Clés */} + + + + + Clés d'API + + + Créez une clé par outil, pour pouvoir en révoquer une sans couper les autres. + La clé n'est affichée qu'une seule fois, à sa création. + + + +
+
+ + setNewKeyName(e.target.value)} + placeholder="Ex. : n8n comptabilité" + maxLength={100} + /> +
+ +
+ + {error ? ( +

Impossible de charger les clés : {errorMessage(error)}

+ ) : isLoading ? ( +

Chargement…

+ ) : activeKeys.length === 0 ? ( +

Aucune clé active.

+ ) : ( +
    + {activeKeys.map((apiKey) => ( +
  • +
    +

    {apiKey.name}

    +

    + {apiKey.keyPrefix}… + {" · "}créée le {formatDateTime(apiKey.createdAt)} + {" · "} + {apiKey.lastUsedAt ? `dernière utilisation le ${formatDateTime(apiKey.lastUsedAt)}` : "jamais utilisée"} +

    +
    + +
  • + ))} +
+ )} + + {(data?.envKeyCount ?? 0) > 0 && ( +

+ {data!.envKeyCount === 1 ? "1 clé est aussi définie" : `${data!.envKeyCount} clés sont aussi définies`} dans + la variable d'environnement EXTERNAL_API_KEYS du serveur + (non modifiables ici). +

+ )} + + {revokedKeys.length > 0 && ( +
+ Clés révoquées ({revokedKeys.length}) +
    + {revokedKeys.map((apiKey) => ( +
  • + {apiKey.name} · {apiKey.keyPrefix}… · révoquée le {formatDateTime(apiKey.revokedAt)} +
  • + ))} +
+
+ )} +
+
+ + {/* Clé créée : affichée une seule fois */} + { if (!open) setCreatedKey(null); }}> + + + Clé « {createdKey?.name} » créée + + Copiez-la maintenant et collez-la dans votre outil : elle ne sera plus jamais affichée. + + +
+ e.currentTarget.select()} aria-label="Clé d'API" /> + {createdKey && } +
+
+ +

Cette clé donne accès aux livraisons de tous les magasins. Ne la partagez pas ; en cas de doute, révoquez-la.

+
+ + + +
+
+ + {/* Confirmation de révocation */} + { if (!open) setKeyToRevoke(null); }}> + + + Révoquer la clé « {keyToRevoke?.name} » ? + + Les outils qui l'utilisent perdront immédiatement l'accès à l'API. Cette action est définitive. + + + + + + + + +
+ ); +} diff --git a/client/src/pages/Utilities.tsx b/client/src/pages/Utilities.tsx index c9f4fcf..c0aed4e 100644 --- a/client/src/pages/Utilities.tsx +++ b/client/src/pages/Utilities.tsx @@ -9,6 +9,7 @@ import { Cloud, Send, Code, + Plug, } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug"; import WeatherSettings from "./WeatherSettings"; import WebhookBAPConfig from "./WebhookBAPConfig"; import SQLExecutor from "./SQLExecutor"; +import ExternalApiSettings from "./ExternalApiSettings"; export default function Utilities() { const { user } = useAuthUnified(); @@ -31,6 +33,7 @@ export default function Utilities() { if (path === '/nocodb-config') return 'nocodb'; if (path === '/database-debug') return 'debug'; if (path === '/weather-settings') return 'weather'; + if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api'; return 'backups'; }); @@ -41,6 +44,7 @@ export default function Utilities() { const canManageWeather = user?.role === 'admin'; const canManageWebhookBAP = user?.role === 'admin'; const canExecuteSQL = user?.role === 'admin'; + const canManageExternalApi = user?.role === 'admin'; // Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) { return ( @@ -78,7 +82,7 @@ export default function Utilities() { {/* Content avec onglets */}
- + {canManageBackups && ( @@ -109,6 +113,12 @@ export default function Utilities() { Configuration BAP )} + {canManageExternalApi && ( + + + API externe + + )} {canExecuteSQL && ( @@ -158,6 +168,14 @@ export default function Utilities() { )} + {canManageExternalApi && ( + +
+ +
+
+ )} + {canExecuteSQL && (
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,