✨ Nouvelles fonctionnalités : - Configuration des URLs Lambda PAdES et TSA dans .env - Certificats Odentas Media SAS (CN=Odentas Seal, O=Odentas Media SAS) - Ajout champs /Name, /Reason, /Location dans signature PDF - Documentation complète des URLs Lambda (ODENTAS_SIGN_LAMBDA_URLS.md) 🔧 Améliorations : - Gestion stricte des erreurs dans webhook completion - Ne marque plus 'completed' si scellage échoue - Vérification des variables LAMBDA_PADES_URL et LAMBDA_TSA_URL - Build Docker multi-arch (ARM64 → AMD64) avec --platform 🔐 Certificats : - CA Root: CN=Odentas Media SAS Root CA, O=Odentas Media SAS - Certificat signature: CN=Odentas Seal, O=Odentas Media SAS, OU=Signature Electronique - Chaîne complète uploadée sur S3 (s3://odentas-sign/certs/chain.pem) ✅ Tests : - Lambda PAdES testée et fonctionnelle - Lambda TSA testée et fonctionnelle - Affichage 'Odentas Media SAS' dans Adobe Reader confirmé ⚠️ Niveau eIDAS actuel : SES (Signature Électronique Simple) TODO: Améliorer conformité PAdES pour niveau AES (voir TODO_PADES_CONFORMITE.md)
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { supabaseAdmin, logSignEvent, getSignEvents } from '@/lib/odentas-sign/supabase';
|
|
import { uploadEvidenceBundle } from '@/lib/odentas-sign/s3';
|
|
import type { EvidenceBundle } from '@/lib/odentas-sign/types';
|
|
|
|
/**
|
|
* POST /api/odentas-sign/webhooks/completion
|
|
*
|
|
* Webhook appelé quand tous les signataires ont signé
|
|
* Lance le workflow de scellage : injection signatures → PAdES → TSA → Archive
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json();
|
|
const { requestId } = body;
|
|
|
|
if (!requestId) {
|
|
return NextResponse.json(
|
|
{ error: 'requestId manquant' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
console.log(`[WEBHOOK COMPLETION] Début traitement pour request ${requestId}`);
|
|
|
|
// 1. Récupérer toutes les données de la demande
|
|
const { data: signRequest, error: requestError } = await supabaseAdmin
|
|
.from('sign_requests')
|
|
.select(`
|
|
*,
|
|
signers(*),
|
|
sign_positions(*)
|
|
`)
|
|
.eq('id', requestId)
|
|
.single();
|
|
|
|
if (requestError || !signRequest) {
|
|
console.error('[WEBHOOK] Erreur récupération demande:', requestError);
|
|
return NextResponse.json(
|
|
{ error: 'Demande introuvable' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Vérifier que tous ont bien signé
|
|
const allSigned = signRequest.signers.every((s: any) => s.signed_at !== null);
|
|
if (!allSigned) {
|
|
return NextResponse.json(
|
|
{ error: 'Tous les signataires n\'ont pas encore signé' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// 2. Logger l'événement de début de scellage
|
|
await logSignEvent({
|
|
requestId: signRequest.id,
|
|
event: 'sealing_started',
|
|
metadata: {
|
|
signers_count: signRequest.signers.length,
|
|
},
|
|
});
|
|
|
|
// 3. Récupérer tous les événements pour le bundle de preuves
|
|
const events = await getSignEvents(requestId);
|
|
|
|
// 4. Créer le bundle de preuves (evidence)
|
|
const evidenceBundle: EvidenceBundle = {
|
|
request_id: signRequest.id,
|
|
request_ref: signRequest.ref,
|
|
title: signRequest.title,
|
|
created_at: signRequest.created_at,
|
|
completed_at: new Date().toISOString(),
|
|
eidas_level: 'SES', // Signature Électronique Simple pour le moment
|
|
signers: signRequest.signers.map((s: any) => ({
|
|
id: s.id,
|
|
role: s.role,
|
|
name: s.name,
|
|
email: s.email,
|
|
signed_at: s.signed_at,
|
|
ip_address: s.ip_signed || 'N/A',
|
|
user_agent: s.user_agent || 'N/A',
|
|
consent_text: s.consent_text,
|
|
consent_at: s.consent_at,
|
|
signature_method: 'drawn', // TODO: détecter drawn vs uploaded
|
|
authentication: {
|
|
method: 'OTP',
|
|
otp_sent_at: events.find((e: any) => e.event === 'otp_sent' && e.signer_id === s.id)?.ts || 'N/A',
|
|
otp_verified_at: events.find((e: any) => e.event === 'otp_verified' && e.signer_id === s.id)?.ts || 'N/A',
|
|
email_verified: true,
|
|
},
|
|
})),
|
|
events: events.map((e: any) => ({
|
|
timestamp: e.ts,
|
|
event: e.event,
|
|
actor: e.signer_id || null,
|
|
ip: e.ip || null,
|
|
metadata: e.metadata,
|
|
})),
|
|
seal: {
|
|
algorithm: 'RSASSA_PSS_SHA_256',
|
|
kms_key_id: process.env.KMS_KEY_ID || 'N/A',
|
|
sealed_at: '',
|
|
pdf_sha256: '',
|
|
},
|
|
tsa: {
|
|
url: process.env.TSA_URL || 'https://timestamp.sectigo.com',
|
|
tsr_sha256: '',
|
|
policy_oid: null,
|
|
serial: null,
|
|
},
|
|
retention: {
|
|
archive_key: '',
|
|
retain_until: '',
|
|
compliance_mode: 'COMPLIANCE',
|
|
},
|
|
};
|
|
|
|
// 5. Upload du bundle de preuves initial (sera mis à jour après scellage)
|
|
const evidenceKey = await uploadEvidenceBundle({
|
|
requestRef: signRequest.ref,
|
|
evidence: evidenceBundle,
|
|
});
|
|
|
|
console.log(`[WEBHOOK] ✅ Evidence bundle uploadé: ${evidenceKey}`);
|
|
|
|
// 6. Workflow de scellage PAdES + TSA
|
|
console.log(`[WEBHOOK] 🔒 Début du workflow de scellage...`);
|
|
|
|
try {
|
|
// Étape 1: Appeler lambda-odentas-pades-sign pour sceller le PDF
|
|
console.log(`[WEBHOOK] 📝 Appel de lambda-odentas-pades-sign...`);
|
|
|
|
const padesPayload = {
|
|
source_s3_key: signRequest.source_s3_key,
|
|
signatures: signRequest.signers.map((s: any) => {
|
|
// Trouver la clé S3 de la signature de ce signataire
|
|
const signatureKey = `signatures/${signRequest.ref}/${s.id}.png`;
|
|
return {
|
|
signer_id: s.id,
|
|
s3_key: signatureKey,
|
|
positions: signRequest.sign_positions
|
|
.filter((p: any) => p.role === s.role)
|
|
.map((p: any) => ({
|
|
page: p.page,
|
|
x: p.x,
|
|
y: p.y,
|
|
width: p.w,
|
|
height: p.h,
|
|
})),
|
|
};
|
|
}),
|
|
output_key: `signed/${signRequest.ref}.pdf`,
|
|
};
|
|
|
|
console.log(`[WEBHOOK] Payload PAdES:`, JSON.stringify(padesPayload, null, 2));
|
|
|
|
// Vérifier que les Lambdas sont configurées
|
|
if (!process.env.LAMBDA_PADES_URL || !process.env.LAMBDA_TSA_URL) {
|
|
const error = 'LAMBDA_PADES_URL et LAMBDA_TSA_URL doivent être configurées pour le scellage';
|
|
console.error(`[WEBHOOK] ❌ ${error}`);
|
|
|
|
// Mettre à jour le statut en 'failed'
|
|
await supabaseAdmin
|
|
.from('sign_requests')
|
|
.update({ status: 'failed' })
|
|
.eq('id', requestId);
|
|
|
|
await logSignEvent({
|
|
requestId: signRequest.id,
|
|
event: 'sealing_failed',
|
|
metadata: { error, reason: 'Lambda URLs not configured' },
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{ error, details: 'Veuillez configurer LAMBDA_PADES_URL et LAMBDA_TSA_URL dans .env' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
// Appel Lambda PAdES
|
|
const padesResponse = await fetch(process.env.LAMBDA_PADES_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(padesPayload),
|
|
}).catch((err) => {
|
|
console.error('[WEBHOOK] ❌ Erreur appel Lambda PAdES:', err.message);
|
|
throw new Error(`Lambda PAdES inaccessible: ${err.message}`);
|
|
});
|
|
|
|
if (!padesResponse.ok) {
|
|
const errorText = await padesResponse.text();
|
|
console.error('[WEBHOOK] ❌ Lambda PAdES a échoué:', errorText);
|
|
throw new Error(`Lambda PAdES failed: ${padesResponse.status} - ${errorText}`);
|
|
}
|
|
|
|
const padesResult = await padesResponse.json();
|
|
console.log(`[WEBHOOK] ✅ PAdES seal appliqué`);
|
|
|
|
const sealedPdfKey = padesResult.signed_pdf_key;
|
|
const pdfHash = padesResult.pdf_sha256;
|
|
|
|
// Étape 2: Appeler lambda-tsaStamp pour horodater
|
|
console.log(`[WEBHOOK] ⏱️ Appel de lambda-tsaStamp...`);
|
|
|
|
const tsaPayload = {
|
|
pdf_s3_key: sealedPdfKey,
|
|
hash_to_timestamp: pdfHash,
|
|
};
|
|
|
|
const tsaResponse = await fetch(process.env.LAMBDA_TSA_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(tsaPayload),
|
|
}).catch((err) => {
|
|
console.error('[WEBHOOK] ❌ Erreur appel Lambda TSA:', err.message);
|
|
throw new Error(`Lambda TSA inaccessible: ${err.message}`);
|
|
});
|
|
|
|
if (!tsaResponse.ok) {
|
|
const errorText = await tsaResponse.text();
|
|
console.error('[WEBHOOK] ❌ Lambda TSA a échoué:', errorText);
|
|
throw new Error(`Lambda TSA failed: ${tsaResponse.status} - ${errorText}`);
|
|
}
|
|
|
|
const tsaResult = await tsaResponse.json();
|
|
console.log(`[WEBHOOK] ✅ TSA timestamp obtenu`);
|
|
|
|
const tsaSerial = tsaResult.serial_number;
|
|
const tsaPolicyOid = tsaResult.policy_oid;
|
|
const tsrKey = tsaResult.tsr_s3_key;
|
|
|
|
// Étape 3: Mettre à jour l'evidence bundle avec les infos de scellage
|
|
evidenceBundle.seal.sealed_at = new Date().toISOString();
|
|
evidenceBundle.seal.pdf_sha256 = pdfHash;
|
|
evidenceBundle.tsa.serial = tsaSerial;
|
|
evidenceBundle.tsa.policy_oid = tsaPolicyOid;
|
|
evidenceBundle.tsa.tsr_sha256 = tsrKey;
|
|
evidenceBundle.retention.archive_key = sealedPdfKey;
|
|
|
|
const retainUntilDate = new Date();
|
|
retainUntilDate.setFullYear(retainUntilDate.getFullYear() + 10);
|
|
evidenceBundle.retention.retain_until = retainUntilDate.toISOString();
|
|
|
|
// Uploader l'evidence bundle mis à jour
|
|
await uploadEvidenceBundle({
|
|
requestRef: signRequest.ref,
|
|
evidence: evidenceBundle,
|
|
});
|
|
|
|
console.log(`[WEBHOOK] ✅ Evidence bundle mis à jour`);
|
|
|
|
// Étape 4: Créer l'enregistrement dans sign_assets
|
|
const { error: assetsError } = await supabaseAdmin
|
|
.from('sign_assets')
|
|
.insert({
|
|
request_id: requestId,
|
|
evidence_json_s3_key: evidenceKey,
|
|
signed_pdf_s3_key: sealedPdfKey,
|
|
tsa_tsr_s3_key: tsrKey || null,
|
|
retain_until: retainUntilDate.toISOString(),
|
|
});
|
|
|
|
if (assetsError) {
|
|
console.error('[WEBHOOK] Erreur création sign_assets:', assetsError);
|
|
}
|
|
|
|
// Étape 5: Mettre à jour le statut de la demande
|
|
const { error: updateError } = await supabaseAdmin
|
|
.from('sign_requests')
|
|
.update({ status: 'completed' })
|
|
.eq('id', requestId);
|
|
|
|
if (updateError) {
|
|
console.error('[WEBHOOK] Erreur mise à jour statut:', updateError);
|
|
}
|
|
|
|
console.log(`[WEBHOOK] ✅ Workflow de scellage terminé`);
|
|
|
|
} catch (sealError) {
|
|
console.error('[WEBHOOK] ❌ Erreur workflow de scellage:', sealError);
|
|
|
|
// Mettre à jour le statut en 'failed' au lieu de 'completed'
|
|
await supabaseAdmin
|
|
.from('sign_requests')
|
|
.update({ status: 'failed' })
|
|
.eq('id', requestId);
|
|
|
|
await logSignEvent({
|
|
requestId: signRequest.id,
|
|
event: 'sealing_failed',
|
|
metadata: {
|
|
error: sealError instanceof Error ? sealError.message : String(sealError),
|
|
},
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Échec du workflow de scellage',
|
|
details: sealError instanceof Error ? sealError.message : String(sealError)
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
// 7. Logger la completion
|
|
await logSignEvent({
|
|
requestId: signRequest.id,
|
|
event: 'request_completed',
|
|
metadata: {
|
|
evidence_key: evidenceKey,
|
|
status: 'completed',
|
|
},
|
|
});
|
|
|
|
// 8. TODO: Envoyer les emails de notification aux signataires
|
|
console.log(`[WEBHOOK] TODO: Envoyer emails de completion`);
|
|
|
|
console.log(`[WEBHOOK COMPLETION] ✅ Traitement terminé pour ${signRequest.ref}`);
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: 'Workflow de scellage terminé',
|
|
request: {
|
|
id: signRequest.id,
|
|
ref: signRequest.ref,
|
|
status: 'completed',
|
|
},
|
|
evidence_key: evidenceKey,
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[WEBHOOK COMPLETION] Erreur:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|