Les points clés de ce retour d'expérience :
- Cycle de validation par Task : Gestion de l'état de signature (WAITING_VALIDATION → VALIDATED) via CreateContractSignatureTask avec verrouillage idempotent des actions déjà exécutées.
- Contrôle des prérequis : Blocage préventif de la signature si l'identité n'est pas vérifiée par KYC (isIdentityVerifiedBool) ou si le mobile n'est pas certifié.
- Sécurisation des envois SMS : Endpoint dédié CheckLastSigningSmsIntervalApi imposant un intervalle minimum de 45 secondes pour éviter le flood.
- Architecture Task/TO : Encapsulation de la logique de création dans CreateContractSignatureTask avec DTO immuable CreateContractSignatureTo.
- Traçabilité complète : Enregistrement du numéro normalisé, du code de contrôle, des timestamps et des logs d'envoi dans LogsSms.
1. Exigences produit et besoin de preuve
Dans la dématérialisation des baux de location, la signature d'un document ne peut pas se résumer à un simple clic sur un bouton web. La décision produit imposait d'apporter des garanties tangibles sur l'identité du signataire et son consentement explicite.
Pour répondre à cette contrainte sans recourir à un tiers payant certifié par acte, j'ai conçu un automate backend articulé autour d'une authentification multifacteur (MFA) par code OTP SMS transmis sur le numéro de mobile certifié du locataire ou de l'hôte.
Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.
2. Flux d'authentification et de validation OTP
Voici le déroulement de la validation d'accord par OTP SMS que j'ai implémenté :
3. Contrôle préventif des prérequis KYC et éligibilité du signataire
Avant même d'autoriser l'envoi d'un code OTP SMS ou d'afficher le composant de signature, le contrôleur vérifie que l'utilisateur répond aux critères de confiance exigés par la plateforme.
Dans la méthode isSignableByUser() du ContractController, la signature est bloquée si la vérification d'identité KYC (gérée par l'intégration Veriff) n'a pas été validée ou si le mobile n'est pas certifié :
private function isSignableByUser(): bool
{
return $this->user->isIdentityVerifiedBool() && $this->user->isMobileVerified() && $this->user->isSmsEligible();
}
Cette vérification amont évite l'envoi de SMS inutiles et garantit qu'un utilisateur non identifié ne peut pas engager de processus contractuel. À noter que ce contrôle d'éligibilité aurait tout aussi bien pu être encapsulé dans une Policy dédiée (ou un Voter Symfony) afin d'isoler totalement cette règle métier du contrôleur.
4. Modèle de données Signature et traçabilité de la preuve
J'ai créé l'entité Doctrine dédiée Signature pour stocker l'empreinte de la preuve sans polluer l'entité de réservation. Chaque étape conserve les métadonnées de l'opération (numéro certifié, code de contrôle, type de partie et statut) :
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
use App\Domain\Entity\Trait\IdTrait;
use App\Domain\Entity\Trait\TimeStampableTrait;
use App\Domain\Enum\SignatureStatus;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'SIGNATURE')]
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Signature
{
use TimeStampableTrait;
use IdTrait;
#[ORM\Column(name: 'NAME', type: Types::STRING, length: 255, nullable: false)]
private string $name;
#[ORM\Column(name: 'VERIFICATION_CODE', type: Types::STRING, length: 16, nullable: false)]
private string $verificationCode;
#[ORM\Column(name: 'MOBILE_NUMBER', type: Types::STRING, length: 32, nullable: false)]
private string $mobileNumber;
#[ORM\Column(name: 'TYPE', type: Types::STRING, length: 64, nullable: false)]
private string $type;
#[ORM\Column(name: 'STATUS', type: Types::STRING, length: 32, nullable: false)]
private string $status = SignatureStatus::WAITING_VALIDATION;
}
Le réemploi du trait TimeStampableTrait assure la conservation exacte des dates de création et de mise à jour (createdAt, updatedAt), constituant le socle de l'horodatage pour l'audit trail.
5. Implémentation de la tâche CreateContractSignatureTask
J'ai isolé la génération du code aléatoire et l'envoi du message SMS transactionnel dans la classe CreateContractSignatureTask :
<?php
declare(strict_types=1);
namespace App\Application\Task\Inbox;
use App\Application\Task\SendSmsTask;
use App\Application\Task\TaskInterface;
use App\Application\Vo\TaskObject\CreateContractSignatureTo;
use App\Application\Vo\TaskObject\TaskObjectInterface;
use App\Domain\Entity\Signature;
use App\Domain\Entity\User;
use App\Domain\Enum\SignatureStatus;
use App\Domain\Enum\SmsType;
use App\Domain\Enum\UserLcSeed;
use App\Domain\Utils\Assert;
use App\Infrastructure\Manager\EntityManager;
use LogicException;
class CreateContractSignatureTask implements TaskInterface
{
public function __construct(
private readonly EntityManager $em,
private readonly SendSmsTask $sendSmsTask,
) {
}
public function exec(TaskObjectInterface $to): void
{
if (!($to instanceof CreateContractSignatureTo)) {
throw new LogicException('TaskObject must be an instance of CreateContractSignatureTo');
}
Assert::true(UserLcSeed::isValid($to->signingSeed), 'Invalid signing seed');
$relationUserGetter = 'get' . \ucfirst($to->signingSeed);
$relationSignatureSetter = 'set' . \ucfirst($to->signingSeed) . 'Signature';
/** @var User $user */
$user = $to->relation->$relationUserGetter();
$signature = new Signature();
$signature->setName($user->getFullName());
$signature->setVerificationCode(\str_pad((string)\random_int(0, 999999), 6, '0', STR_PAD_LEFT));
$signature->setMobileNumber($user->getMobile());
$signature->setType('relations_' . $to->signingSeed);
$signature->setStatus(SignatureStatus::WAITING_VALIDATION);
$to->relation->$relationSignatureSetter($signature);
$this->sendSmsTask
->reset()
->withRecipient($user)
->withRelation($to->relation)
->withSmsType(SmsType::SIGNING_VERIFICATION_CODE)
->withSignature($signature)
->exec();
$this->em->persist($to->relation);
$this->em->flush();
}
}
6. API de signature et transition d'état (ContractSigningApi)
Lorsqu'un utilisateur saisit le code OTP reçu par SMS, le front-end effectue un appel POST vers l'endpoint ContractSigningApi. Cet API gère la vérification et la transition d'état de l'entité Signature de WAITING_VALIDATION vers VALIDATED.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Front\MySpace\Inbox;
use App\Application\Task\Inbox\CreateContractSignatureTask;
use App\Application\Task\DispatchSmsTask;
use App\Application\Vo\TaskObject\CreateContractSignatureTo;
use App\Domain\Entity\Booking;
use App\Domain\Entity\Signature;
use App\Domain\Enum\ContractSigningApiResponseStatus;
use App\Domain\Enum\SignatureStatus;
use App\Domain\Enum\SmsNotificationType;
use App\Domain\Enum\UserLcSeed;
use App\Domain\Manager\EntityManagerInterface;
use App\Domain\Utils\Assert;
use App\Infrastructure\Api\Front\Api;
use Exception;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class ContractSigningApi extends Api
{
public function __construct(
private EntityManagerInterface $em,
private readonly CreateContractSignatureTask $createContractSignatureTask,
private readonly DispatchSmsTask $sendSmsTask,
) {
}
#[Route(
path: '/api/myspace/inbox/{booking}/sign',
name: 'api_myspace_inbox_relation_sign',
methods: ['POST'],
priority: 1,
)]
public function __invoke(?Booking $booking, Request $request): JsonResponse
{
if (!$booking) {
return new JsonResponse(['status' => ContractSigningApiResponseStatus::RELATION_NOT_FOUND]);
}
$signAs = $request->request->getString('signingSeed');
Assert::true(UserLcSeed::isValid($signAs), 'seed de signature invalide');
$verificationCode = $request->request->getString('verificationCode');
/** @var ?Signature $signature */
$signature = $booking->getSignatureForSeed($signAs);
if (!$signature) {
$to = new CreateContractSignatureTo(
signatureType: 'relations_' . $signAs,
signatureStatus: SignatureStatus::WAITING_VALIDATION,
signingSeed: $signAs,
booking: $booking
);
$this->createContractSignatureTask->exec($to);
return new JsonResponse(['status' => ContractSigningApiResponseStatus::CREATED_WAITING_CODE]);
}
if (SignatureStatus::VALIDATED === $signature->getStatus()) {
return new JsonResponse(['status' => ContractSigningApiResponseStatus::SIGNATURE_ALLRELISTINGY_OK]);
}
if (!$verificationCode) {
return new JsonResponse(['status' => ContractSigningApiResponseStatus::WAITING_VERIFICATION_CODE]);
}
if ($verificationCode !== $signature->getVerificationCode()) {
return new JsonResponse(['status' => ContractSigningApiResponseStatus::BAD_VERIFICATION_CODE]);
}
$signature->setStatus(SignatureStatus::VALIDATED);
$this->em->flush();
return new JsonResponse(['status' => ContractSigningApiResponseStatus::OK, 'userPhone' => $this->user->getMobile()]);
}
}
Si le code saisi ne correspond pas à la valeur stockée en base de données, l'API renvoie le statut BAD_VERIFICATION_CODE sans altérer la signature. Une fois validée, la signature passe à VALIDATED et devient immuable.
7. Protection anti-flood et contrôle d'intervalle (45s)
J'ai mis en place l'endpoint CheckLastSigningSmsIntervalApi pour interroger la table des logs SMS et imposer un délai de garde de 45 secondes :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Front\MySpace\Inbox;
use App\Domain\Enum\SmsSendStatus;
use App\Domain\Enum\SmsType;
use App\Infrastructure\Api\Front\Api;
use App\Infrastructure\Repository\Doctrine\LogsSmsRepository;
use DateTime;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
class CheckLastSigningSmsIntervalApi extends Api
{
private const SMS_SHORT_QUOTA_MIN_INTERVAL_SECONDS = 45;
public function __construct(
private readonly LogsSmsRepository $logsSmsRepository,
) {
}
#[Route(
path: '/myspace/inbox/contract/check-last-sms-interval',
name: 'myspace_inbox_contract_checkLastSmsInterval',
methods: ['GET'],
priority: 1
)]
public function __invoke(): JsonResponse
{
$lastSms = $this->logsSmsRepository->findLastByUserStatusAndTypes(
$this->user,
SmsSendStatus::OK,
[SmsType::SIGNING_VERIFICATION_CODE]
);
if (!$lastSms) {
return new JsonResponse(['canUserSendSms' => true]);
}
$elapsed = (new DateTime())->getTimestamp() - $lastSms->getDateInsert()->getTimestamp();
return new JsonResponse([
'lastSmsDateInsertTimestamp' => $lastSms->getDateInsert()->getTimestamp(),
'minSmsQuotaIntervalSeconds' => self::SMS_SHORT_QUOTA_MIN_INTERVAL_SECONDS,
'canUserSendSms' => $elapsed >= self::SMS_SHORT_QUOTA_MIN_INTERVAL_SECONDS,
]);
}
}
8. Stratégie de tests PHPUnit et contrôle des erreurs d'envoi
Pour s'assurer que la table des logs SMS et la vérification des quotas fonctionnent correctement, les tests PHPUnit valident le comportement de LogsSmsRepository en présence de messages valides et d'erreurs d'envoi :
<?php
declare(strict_types=1);
namespace Tests\Infrastructure\Repository\Doctrine;
use App\Application\Facade\Carbon;
use App\Application\Task\DispatchSmsTask;
use App\Domain\Entity\LogsSms;
use App\Domain\Entity\User;
use App\Domain\Enum\SmsNotificationType;
use App\Domain\Enum\SmsPlatformStatus;
use App\Domain\Enum\SmsStatus;
use App\Infrastructure\Repository\Doctrine\LogsSmsRepository;
use DateTime;
use Tests\AppKernelTest;
use Tests\Infrastructure\TestsUtils\Builder\UserBuilder;
final class LogsSmsRepositoryCheckErrorsTest extends AppKernelTest
{
private LogsSmsRepository $logsSmsRepository;
protected function setUp(): void
{
parent::setUp();
$this->logsSmsRepository = self::getContainer()->get(LogsSmsRepository::class);
}
public function testCheckErrorsByNumberWithValidAndErrorSms(): void
{
$landlordBuilder = UserBuilder::init(self::getContainer());
$host = $landlordBuilder->getUser();
$host->setMobile($mobile = '+336' . mt_rand(10000000, 99999999));
$this->insertSms($host, Carbon::today(), SmsStatus::KO, SmsPlatformStatus::ERROR_API, null);
$this->insertSms($host, Carbon::today(), SmsStatus::OK, SmsPlatformStatus::OK, null);
$this->insertSms($host, Carbon::today(), SmsStatus::OK, SmsPlatformStatus::ERROR_LONG_QUOTA, null);
$this->insertSms($host, Carbon::today(), SmsStatus::OK, SmsPlatformStatus::ERROR_SHORT_QUOTA, null);
$this->logsSmsRepository->checkErrorsByNumber($mobile, DispatchSmsTask::MAX_NB_ERRORS, includeQuotas: false);
$this->assertTrue(true);
}
private function insertSms(User $user, DateTime $dateInsert, string $status, ?string $platformStatus, ?string $statusDetails): void
{
$sms = new LogsSms();
$sms->setType(SmsNotificationType::SIGNING_VERIFICATION_CODE);
$sms->setPlatformStatus($platformStatus);
$sms->setStatus($status);
$sms->setStatusDetails($statusDetails);
$sms->setNumber($user->getMobile());
$sms->setUserId($user->getId());
$this->getEntityManager()->persist($sms);
$this->getEntityManager()->flush();
}
}
9. Enseignements et arbitrages
Dans mon travail backend, ce pattern de validation MFA s'est montré très efficace :
- Découplage strict : Le contrôleur HTTP se borne à instancier le DTO CreateContractSignatureTo et à déléguer à la tâche.
- Maîtrise des coûts : La vérification d'intervalle à 45 secondes protège l'application des spams SMS tout en offrant un retour visuel clair avec décompte temps réel.
- Garantie légale et audit : La double validation KYC préalable et MFA OTP couplée aux journaux LogsSms offre une traçabilité solide en cas de litige.