Skip to content

How-to : Construire un système de messagerie directe avec NENE2

Référence FT : FT278 (NENE2-FT/messagelog) — Messagerie directe : threading de conversation, UNIQUE(initiator_id, recipient_id) + CHECK(initiator_id != recipient_id), contrôle d'accès participant uniquement, recherche agnostique au sens, démarrage de conversation idempotent, 31 tests / 96 assertions PASS.

Aussi validé en FT135 — implémentation originale.

Ce guide décrit la construction d'un système de messages directs (DM) style Twitter/Instagram — les utilisateurs démarrent des conversations entre eux, envoient des messages, et seuls les participants peuvent lire ou envoyer dans une conversation.

Version NENE2 : ^1.5 Sujets couverts : threading de conversation, contrôle d'accès participant, recherche de conversation agnostique au sens, démarrage de conversation idempotent


Ce que nous construisons

Une API REST où :

  • Deux utilisateurs quelconques peuvent démarrer une conversation (idempotent — redémarrer retourne l'existante)
  • Seuls les participants peuvent envoyer des messages ou lire les messages d'une conversation
  • Un utilisateur peut lister ses propres conversations (mais pas celles d'un autre utilisateur)
  • Les messages sont ordonnés du plus ancien au plus récent dans une conversation

Schéma de la base de données

sql
CREATE TABLE users (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    created_at TEXT NOT NULL
);

CREATE TABLE conversations (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    initiator_id INTEGER NOT NULL,
    recipient_id INTEGER NOT NULL,
    created_at   TEXT    NOT NULL,
    UNIQUE (initiator_id, recipient_id),
    CHECK  (initiator_id != recipient_id),
    FOREIGN KEY (initiator_id) REFERENCES users(id),
    FOREIGN KEY (recipient_id) REFERENCES users(id)
);

CREATE TABLE messages (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    conversation_id INTEGER NOT NULL,
    sender_id       INTEGER NOT NULL,
    content         TEXT    NOT NULL,
    created_at      TEXT    NOT NULL,
    FOREIGN KEY (conversation_id) REFERENCES conversations(id),
    FOREIGN KEY (sender_id)       REFERENCES users(id)
);

La contrainte UNIQUE (initiator_id, recipient_id) impose une conversation par paire ordonnée. La couche applicative gère le sens inverse (Bob→Alice retourne la même conversation qu'Alice→Bob).


Endpoints API

MéthodeCheminDescription
POST/usersCréer un utilisateur
POST/conversationsDémarrer une conversation (idempotent)
POST/conversations/{id}/messagesEnvoyer un message (participants uniquement)
GET/conversations/{id}/messagesLire les messages (participants uniquement, X-User-Id)
GET/users/{userId}/conversationsLister les conversations d'un utilisateur (soi uniquement, X-User-Id)

Recherche de conversation agnostique au sens

Le défi clé : Alice démarre une conversation avec Bob (initiator=Alice, recipient=Bob). Plus tard, Bob en démarre aussi une avec Alice. Ils devraient obtenir la même conversation, pas deux séparées.

php
public function findConversation(int $userA, int $userB): ?int
{
    $row = $this->executor->fetchOne(
        'SELECT id FROM conversations
         WHERE (initiator_id = ? AND recipient_id = ?)
            OR (initiator_id = ? AND recipient_id = ?)',
        [$userA, $userB, $userB, $userA],
    );

    if ($row === null) {
        return null;
    }

    $arr = (array) $row;

    return isset($arr['id']) ? (int) $arr['id'] : null;
}

public function findOrCreateConversation(int $initiatorId, int $recipientId, string $now): int
{
    $existing = $this->findConversation($initiatorId, $recipientId);

    if ($existing !== null) {
        return $existing;
    }

    $this->executor->execute(
        'INSERT INTO conversations (initiator_id, recipient_id, created_at) VALUES (?, ?, ?)',
        [$initiatorId, $recipientId, $now],
    );

    return (int) $this->executor->lastInsertId();
}

Vérification de participant

Avant de lire les messages ou d'envoyer, vérifier que l'appelant est dans la conversation :

php
public function isParticipant(int $conversationId, int $userId): bool
{
    return $this->executor->fetchOne(
        'SELECT id FROM conversations
         WHERE id = ? AND (initiator_id = ? OR recipient_id = ?)',
        [$conversationId, $userId, $userId],
    ) !== null;
}

Identité de l'acteur — en-tête X-User-Id

Les endpoints protégés utilisent un en-tête simple X-User-Id pour identifier l'appelant. Les systèmes en production utiliseraient plutôt un claim JWT.

php
private function resolveActorId(ServerRequestInterface $request): int
{
    $header = $request->getHeaderLine('X-User-Id');

    return is_numeric($header) ? (int) $header : 0;
}

Note : is_numeric() retourne false pour les chaînes non numériques, donc X-User-Id: adminactorId = 0 → 404.


Gestionnaire d'envoi de message

php
private function sendMessage(ServerRequestInterface $request): ResponseInterface
{
    $params         = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $conversationId = isset($params['conversationId']) && is_numeric($params['conversationId'])
        ? (int) $params['conversationId'] : 0;

    if ($conversationId <= 0 || $this->repo->findConversationById($conversationId) === null) {
        return $this->responseFactory->create(['error' => 'conversation not found'], 404);
    }

    $body     = JsonRequestBodyParser::parse($request);
    $senderId = isset($body['sender_id']) && is_int($body['sender_id']) ? $body['sender_id'] : 0;
    $content  = isset($body['content']) && is_string($body['content']) ? trim($body['content']) : '';

    if ($senderId <= 0 || !$this->repo->findUserById($senderId)) {
        return $this->responseFactory->create(['error' => 'sender not found'], 404);
    }

    if (!$this->repo->isParticipant($conversationId, $senderId)) {
        return $this->responseFactory->create(['error' => 'not a participant'], 403);
    }

    if ($content === '') {
        return $this->responseFactory->create(['error' => 'content is required'], 422);
    }

    $now       = date('Y-m-d H:i:s');
    $messageId = $this->repo->sendMessage($conversationId, $senderId, $content, $now);

    return $this->responseFactory->create([...], 201);
}

Ordre des vérifications : la conversation existe → l'expéditeur existe → l'expéditeur est participant → contenu valide. Les vérifications d'existence avant les vérifications d'accès empêchent la fuite d'informations sur les IDs de conversation.


Gestionnaire de lecture de messages — GET sans corps

Pour les endpoints GET qui nécessitent une identité (listMessages, listUserConversations), l'acteur vient de l'en-tête X-User-Id. Ne pas appeler JsonRequestBodyParser::parse() sur les requêtes GET — il retourne 400 car les requêtes GET n'ont pas de corps JSON.

php
private function listMessages(ServerRequestInterface $request): ResponseInterface
{
    $params         = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $conversationId = isset($params['conversationId']) && is_numeric($params['conversationId'])
        ? (int) $params['conversationId'] : 0;

    if ($conversationId <= 0 || $this->repo->findConversationById($conversationId) === null) {
        return $this->responseFactory->create(['error' => 'conversation not found'], 404);
    }

    // Pas de JsonRequestBodyParser::parse() ici — l'acteur vient uniquement de l'en-tête
    $actorId = $this->resolveActorId($request);

    if ($actorId <= 0 || !$this->repo->findUserById($actorId)) {
        return $this->responseFactory->create(['error' => 'actor not found'], 404);
    }

    if (!$this->repo->isParticipant($conversationId, $actorId)) {
        return $this->responseFactory->create(['error' => 'not a participant'], 403);
    }

    $messages = $this->repo->listMessages($conversationId);

    return $this->responseFactory->create(['items' => $messages, 'count' => count($messages)]);
}

Ordre des messages

Les messages utilisent ORDER BY id ASC — le plus ancien en premier, correspondant aux conventions UI de chat. Les listes follow/notification utilisent ORDER BY id DESC (le plus récent en premier). Choisir en fonction des attentes de l'interface.


Évaluation des vulnérabilités (FT135)

Douze tests de vulnérabilité vérifient :

IDAttaqueAttenduRésultat
VULN-ALire les messages de la conversation d'un autre utilisateur (IDOR)403Pass
VULN-BEnvoyer un message à une conversation dont on ne fait pas partie (IDOR)403Pass
VULN-CLire la liste de conversation d'un autre utilisateur (IDOR)403Pass
VULN-DX-User-Id manquant sur list messages404/403Pass
VULN-EX-User-Id manquant sur liste de conversation403Pass
VULN-FID utilisateur négatif dans le chemin404Pass
VULN-GID de conversation zéro dans le chemin404Pass
VULN-HEn-tête X-User-Id non numériquepas 200Pass
VULN-IInjection SQL dans le contenu du message201 (stocké verbatim)Pass
VULN-JXSS dans le contenu du message201 (stocké verbatim)Pass
VULN-KTentative d'auto-conversation422Pass
VULN-LContenu de message 100 Ko201 ou 413Pass

Les 12 tests de vulnérabilité passent. Aucune vulnérabilité trouvée.


Pièges courants

PiègeCorrection
Appeler JsonRequestBodyParser::parse() sur les requêtes GETL'appeler uniquement pour les gestionnaires POST/PUT/PATCH qui attendent un corps
UNIQUE (initiator_id, recipient_id) n'empêche pas A→B et B→A comme deux conversationsChercher de façon agnostique au sens avec une requête OR avant INSERT
Vérifier le participant après la validité du contenuVérifier le participant avant le contenu pour éviter de fuiter des infos
Accepter tout entier non zéro comme actorID sans vérifier l'existence de l'utilisateurToujours vérifier findUserById(actorId) avant de vérifier la participation

Ce qu'il ne faut PAS faire

Anti-patternRisque
Stocker les conversations comme (user_a, user_b) avec sens — deux lignes séparées pour A→B et B→ALes deux mêmes utilisateurs accumulent des conversations en double ; la recherche agnostique au sens échoue
Pas de contrainte CHECK (initiator_id != recipient_id)Les utilisateurs peuvent se envoyer des messages à eux-mêmes, créant des auto-conversations confuses
Pas de contrainte UNIQUE (initiator_id, recipient_id)Les requêtes de démarrage de conversation concurrentes créent des lignes en double pour la même paire
Retourner 404 au lieu de 403 sur l'accès non-participantRévèle l'existence de l'ID de conversation aux non-participants
Appeler JsonRequestBodyParser::parse() sur GET /conversations/{id}/messagesLes requêtes GET n'ont pas de corps ; le parser retourne 400
Vérifier la validité du contenu avant la vérification du participantFuite d'informations — l'attaquant peut sonder les IDs de conversation valides en envoyant du contenu vide et en observant 403 vs 422
Utiliser is_numeric() sans cast vers int puis > 0is_numeric("0") est true ; l'ID utilisateur 0 serait traité comme valide
Sauter la vérification d'existence utilisateur après la vérification de participantisParticipant() vérifie uniquement les FK — les utilisateurs supprimés ou inexistants peuvent toujours apparaître si la DB n'a pas de cascade
Autoriser n'importe quel utilisateur à lister les conversations d'un autreIDOR — toujours vérifier actorId === targetUserId avant de retourner la liste de conversations
Indexer uniquement sur conversation_id pour les messagesIndex id ASC manquant cause un ORDER BY lent sur de grands historiques de messages

Publié sous licence MIT.