- Remplacement de DocuSeal par solution souveraine Odentas Sign - Système d'authentification OTP pour signataires (bcryptjs + JWT) - 8 routes API: send-otp, verify-otp, sign, pdf-url, positions, status, webhook, signers - Interface moderne avec canvas de signature et animations (framer-motion, confetti) - Système de templates pour auto-détection des positions de signature (CDDU, RG, avenants) - PDF viewer avec @react-pdf-viewer (compatible Next.js) - Stockage S3: source/, signatures/, evidence/, signed/, certs/ - Tables Supabase: sign_requests, signers, sign_positions, sign_events, sign_assets - Evidence bundle automatique (JSON metadata + timestamps) - Templates emails: OTP et completion - Scripts Lambda prêts: pades-sign (KMS seal) et tsaStamp (RFC3161) - Mode test détecté automatiquement (emails whitelist) - Tests complets avec PDF CDDU réel (2 signataires)
186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase';
|
|
import { verifyOTP, isOTPExpired } from '@/lib/odentas-sign/crypto';
|
|
import { createSignatureSession } from '@/lib/odentas-sign/jwt';
|
|
|
|
const MAX_OTP_ATTEMPTS = 3;
|
|
|
|
/**
|
|
* POST /api/odentas-sign/signers/[id]/verify-otp
|
|
*
|
|
* Vérifie le code OTP et crée une session de signature
|
|
*/
|
|
export async function POST(
|
|
request: NextRequest,
|
|
{ params }: { params: { id: string } }
|
|
) {
|
|
try {
|
|
const signerId = params.id;
|
|
const body = await request.json();
|
|
const { otp } = body;
|
|
|
|
if (!otp || !/^\d{6}$/.test(otp)) {
|
|
return NextResponse.json(
|
|
{ error: 'Code OTP invalide (6 chiffres requis)' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Récupérer le signataire
|
|
const { data: signer, error: signerError } = await supabaseAdmin
|
|
.from('signers')
|
|
.select('*, sign_requests(id, ref, title, status)')
|
|
.eq('id', signerId)
|
|
.single();
|
|
|
|
if (signerError || !signer) {
|
|
return NextResponse.json(
|
|
{ error: 'Signataire introuvable' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Vérifier que la demande est active
|
|
if (signer.sign_requests.status !== 'pending' && signer.sign_requests.status !== 'in_progress') {
|
|
return NextResponse.json(
|
|
{ error: 'Cette demande de signature n\'est plus active' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Vérifier si déjà signé
|
|
if (signer.signed_at) {
|
|
return NextResponse.json(
|
|
{ error: 'Vous avez déjà signé ce document' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Vérifier si un OTP existe
|
|
if (!signer.otp_hash) {
|
|
return NextResponse.json(
|
|
{ error: 'Aucun code OTP n\'a été généré. Veuillez en demander un.' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Vérifier si l'OTP est expiré
|
|
if (isOTPExpired(signer.otp_expires_at)) {
|
|
return NextResponse.json(
|
|
{ error: 'Le code OTP a expiré. Veuillez en demander un nouveau.' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Vérifier le nombre de tentatives
|
|
if (signer.otp_attempts >= MAX_OTP_ATTEMPTS) {
|
|
await logSignEvent({
|
|
requestId: signer.sign_requests.id,
|
|
signerId: signer.id,
|
|
event: 'otp_max_attempts_exceeded',
|
|
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
|
userAgent: request.headers.get('user-agent') || undefined,
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{ error: 'Nombre maximum de tentatives atteint. Veuillez demander un nouveau code.' },
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
|
|
// Vérifier le code OTP
|
|
const isValid = await verifyOTP(otp, signer.otp_hash);
|
|
|
|
if (!isValid) {
|
|
// Incrémenter le compteur de tentatives
|
|
const newAttempts = signer.otp_attempts + 1;
|
|
|
|
await supabaseAdmin
|
|
.from('signers')
|
|
.update({ otp_attempts: newAttempts })
|
|
.eq('id', signerId);
|
|
|
|
await logSignEvent({
|
|
requestId: signer.sign_requests.id,
|
|
signerId: signer.id,
|
|
event: 'otp_verification_failed',
|
|
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
|
userAgent: request.headers.get('user-agent') || undefined,
|
|
metadata: {
|
|
attempt: newAttempts,
|
|
max_attempts: MAX_OTP_ATTEMPTS,
|
|
},
|
|
});
|
|
|
|
const remainingAttempts = MAX_OTP_ATTEMPTS - newAttempts;
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: `Code incorrect. ${remainingAttempts} tentative${remainingAttempts > 1 ? 's' : ''} restante${remainingAttempts > 1 ? 's' : ''}.`,
|
|
remainingAttempts,
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// ✅ Code OTP valide !
|
|
console.log(`[OTP] ✅ Code vérifié pour ${signer.email}`);
|
|
|
|
// Réinitialiser les tentatives
|
|
await supabaseAdmin
|
|
.from('signers')
|
|
.update({
|
|
otp_attempts: 0,
|
|
})
|
|
.eq('id', signerId);
|
|
|
|
// Logger l'événement
|
|
await logSignEvent({
|
|
requestId: signer.sign_requests.id,
|
|
signerId: signer.id,
|
|
event: 'otp_verified',
|
|
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
|
userAgent: request.headers.get('user-agent') || undefined,
|
|
});
|
|
|
|
// Mettre à jour le statut de la demande à "in_progress" si c'est la première vérification
|
|
if (signer.sign_requests.status === 'pending') {
|
|
await supabaseAdmin
|
|
.from('sign_requests')
|
|
.update({ status: 'in_progress' })
|
|
.eq('id', signer.sign_requests.id);
|
|
}
|
|
|
|
// Créer un JWT de session (valide 30 minutes)
|
|
const sessionToken = createSignatureSession({
|
|
signerId: signer.id,
|
|
requestId: signer.sign_requests.id,
|
|
email: signer.email,
|
|
role: signer.role,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: 'Code vérifié avec succès',
|
|
sessionToken,
|
|
signer: {
|
|
id: signer.id,
|
|
name: signer.name,
|
|
email: signer.email,
|
|
role: signer.role,
|
|
},
|
|
request: {
|
|
id: signer.sign_requests.id,
|
|
ref: signer.sign_requests.ref,
|
|
title: signer.sign_requests.title,
|
|
},
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[VERIFY OTP] Erreur:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|