feat(securite): encrypt SMTP passwords and NocoDB tokens at rest, log supplier mails

Encryption (server/crypto.ts):
- AES-256-GCM with a key derived from ENCRYPTION_KEY, falling back to
  SESSION_SECRET so existing deployments need no new configuration
- stored format enc:v1:<iv>:<tag>:<data>; encryption is idempotent and
  decryption passes legacy plaintext through unchanged, so nothing breaks
  mid-migration
- tampered data or a changed key raises an explicit error instead of
  returning garbage
- encrypt/decrypt is confined to the storage layer: group writes encrypt
  smtpPassword (decrypted only in emailService at connection time, never sent
  to the client), NocoDB config writes encrypt apiToken and reads decrypt it
  so the invoice verification and the admin page behave as before
- startup migration sweep encrypts secrets already stored in plaintext,
  idempotently; the active-config log line no longer prints the token

Mail history (supplier_mail_logs):
- every send attempt is recorded: delivery, store, supplier, recipient,
  subject, status sent/failed with error, message id, user id and name;
  a logging failure never fails the send itself
- GET /api/supplier-mail-logs restricted to the user's stores (admin may
  filter by store)
- on the reconciliation page the mail icon turns green once a request has
  been sent, with the date and sender in the tooltip; clicking again resends
- table created in init.sql, versioned migrations and the production
  startup migration, with delivery/group indexes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
Claude committed 2026-08-14 16:02:55 +00:00
1 parent 012024a293
commit 9de717387f
9 files changed
+433 -40

No files matched your search

+100 -10
View File
@@ -13,6 +13,7 @@ import {
dashboardMessages,
announcements,
nocodbConfig,
supplierMailLogs,
invoiceVerificationCache,
reconciliationComments,
savTickets,
@@ -58,6 +59,8 @@ import {
type ReconciliationCommentWithRelations,
type NocodbConfig,
type InsertNocodbConfig,
type SupplierMailLog,
type InsertSupplierMailLog,
type InvoiceVerificationCache,
type InsertInvoiceVerificationCache,
type SavTicket,
@@ -83,6 +86,7 @@ import {
type Contact,
type InsertContact,
} from "@shared/schema";
import { encryptSecret, decryptSecret } from "./crypto";
import { db } from "./db";
import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne } from "drizzle-orm";
import { getAnnouncementStorage } from "./announcementStorage";
@@ -270,6 +274,10 @@ export interface IStorage {
deleteOldWeatherData(daysToKeep: number): Promise<void>;
clearWeatherCache(): Promise<void>;
// Historique des mails fournisseurs (rapprochement)
createSupplierMailLog(log: InsertSupplierMailLog): Promise<SupplierMailLog>;
getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]>;
// Webhook BAP Configuration
getWebhookBapConfig(): Promise<WebhookBapConfig | undefined>;
createWebhookBapConfig(config: InsertWebhookBapConfig): Promise<WebhookBapConfig>;
@@ -428,14 +436,21 @@ export class DatabaseStorage implements IStorage {
}
async createGroup(groupData: InsertGroup): Promise<Group> {
const [group] = await db.insert(groups).values(groupData).returning();
// Le mot de passe SMTP est chiffré au repos ; il n'est déchiffré qu'au
// moment de l'envoi d'un mail (emailService)
const values = { ...groupData, smtpPassword: encryptSecret(groupData.smtpPassword) };
const [group] = await db.insert(groups).values(values).returning();
return group;
}
async updateGroup(id: number, groupData: Partial<InsertGroup>): Promise<Group> {
const values = { ...groupData };
if (values.smtpPassword !== undefined) {
values.smtpPassword = encryptSecret(values.smtpPassword);
}
const [group] = await db
.update(groups)
.set({ ...groupData, updatedAt: new Date() })
.set({ ...values, updatedAt: new Date() })
.where(eq(groups.id, id))
.returning();
return group;
@@ -1622,13 +1637,26 @@ export class DatabaseStorage implements IStorage {
}
// NocoDB Configuration operations
// Le jeton API est chiffré en base ; les lecteurs (vérification de facture,
// page d'admin) reçoivent la valeur en clair comme avant le chiffrement
private decryptNocodbConfig<T extends NocodbConfig | undefined>(config: T): T {
if (!config) return config;
try {
return { ...config, apiToken: decryptSecret(config.apiToken) } as T;
} catch (error) {
console.error(`❌ Jeton NocoDB illisible (config ${config.id}):`, error);
return { ...config, apiToken: '' } as T;
}
}
async getNocodbConfigs(): Promise<NocodbConfig[]> {
return await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c));
}
async getNocodbConfig(id: number): Promise<NocodbConfig | undefined> {
const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id));
return config;
return this.decryptNocodbConfig(config);
}
async getActiveNocodbConfig(): Promise<NocodbConfig | undefined> {
@@ -1639,8 +1667,9 @@ export class DatabaseStorage implements IStorage {
.where(eq(nocodbConfig.isActive, true))
.limit(1);
console.log('🔧 Configuration NocoDB active récupérée:', config);
return config;
// Ne pas tracer le jeton en clair dans les logs
console.log('🔧 Configuration NocoDB active récupérée:', config?.id, config?.name);
return this.decryptNocodbConfig(config);
} catch (error) {
console.error('❌ Erreur récupération config NocoDB:', error);
return undefined;
@@ -1648,17 +1677,22 @@ export class DatabaseStorage implements IStorage {
}
async createNocodbConfig(configData: InsertNocodbConfig): Promise<NocodbConfig> {
const [config] = await db.insert(nocodbConfig).values(configData).returning();
return config;
const values = { ...configData, apiToken: encryptSecret(configData.apiToken) || '' };
const [config] = await db.insert(nocodbConfig).values(values).returning();
return this.decryptNocodbConfig(config);
}
async updateNocodbConfig(id: number, configData: Partial<InsertNocodbConfig>): Promise<NocodbConfig> {
const values = { ...configData };
if (values.apiToken !== undefined) {
values.apiToken = encryptSecret(values.apiToken) || '';
}
const [config] = await db
.update(nocodbConfig)
.set({ ...configData, updatedAt: new Date() })
.set({ ...values, updatedAt: new Date() })
.where(eq(nocodbConfig.id, id))
.returning();
return config;
return this.decryptNocodbConfig(config);
}
async deleteNocodbConfig(id: number): Promise<void> {
@@ -2912,6 +2946,29 @@ export class DatabaseStorage implements IStorage {
console.log('🧹 Weather cache cleared due to location change');
}
// Historique des mails fournisseurs (rapprochement)
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
const [log] = await db.insert(supplierMailLogs).values(logData).returning();
return log;
}
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
const conditions = [];
if (groupIds && groupIds.length > 0) {
conditions.push(inArray(supplierMailLogs.groupId, groupIds));
}
if (deliveryId !== undefined) {
conditions.push(eq(supplierMailLogs.deliveryId, deliveryId));
}
let query = db.select().from(supplierMailLogs).$dynamic();
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
// Borné : la page de rapprochement n'a besoin que de l'historique récent
return await query.orderBy(desc(supplierMailLogs.createdAt)).limit(500);
}
// Webhook BAP Configuration
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
const [config] = await db.select().from(webhookBapConfig).limit(1);
@@ -5209,6 +5266,39 @@ export class MemStorage implements IStorage {
console.log('🧹 DEV: Weather cache cleared due to location change');
}
// Historique des mails fournisseurs (en mémoire pour le développement)
private supplierMailLogsStore: SupplierMailLog[] = [];
private supplierMailLogIdCounter = 1;
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
const log: SupplierMailLog = {
id: this.supplierMailLogIdCounter++,
deliveryId: logData.deliveryId,
groupId: logData.groupId,
supplierId: logData.supplierId ?? null,
supplierName: logData.supplierName ?? null,
sentTo: logData.sentTo,
subject: logData.subject ?? null,
status: logData.status,
errorMessage: logData.errorMessage ?? null,
messageId: logData.messageId ?? null,
sentBy: logData.sentBy,
sentByName: logData.sentByName ?? null,
createdAt: new Date(),
};
this.supplierMailLogsStore.unshift(log);
return log;
}
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
return this.supplierMailLogsStore
.filter(log =>
(!groupIds || groupIds.length === 0 || groupIds.includes(log.groupId)) &&
(deliveryId === undefined || log.deliveryId === deliveryId)
)
.slice(0, 500);
}
// Webhook BAP Configuration
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
// En développement, retourner une config par défaut