Skip to content

Système d'invitation d'utilisateurs ​

Invitez de nouveaux utilisateurs par email, appliquez l'expiration, et prévenez les abus avec des invitations basées sur des tokens.

Vue d'ensemble ​

Un système d'invitation permet aux utilisateurs existants de parrainer la création de nouveaux comptes. Les invariants clés sont :

  • Les tokens sont cryptographiquement aléatoires et non devinables.
  • L'expiration est vérifiée à la fois en lecture et en écriture.
  • Seul l'invitant original peut annuler une invitation.
  • Les tokens acceptés et annulés ne peuvent pas être réutilisés.

Schéma de base de données ​

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

CREATE TABLE invitations (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    inviter_id  INTEGER NOT NULL,
    email       TEXT    NOT NULL,
    token       TEXT    NOT NULL UNIQUE,
    status      TEXT    NOT NULL DEFAULT 'pending',
    expires_at  TEXT    NOT NULL,
    accepted_at TEXT,
    created_at  TEXT    NOT NULL,
    FOREIGN KEY (inviter_id) REFERENCES users(id)
);

Génération du token ​

Toujours utiliser bin2hex(random_bytes(32)) — 64 caractères hex, 256 bits d'entropie :

php
$token = bin2hex(random_bytes(32));

Ne jamais utiliser des IDs séquentiels, des UUIDs, ou des chaînes courtes comme tokens d'invitation. Un token devinable permet à un attaquant d'accepter n'importe quelle invitation en attente.

Envoyer une invitation ​

Avant de créer l'invitation, vérifier que l'email cible n'est pas déjà enregistré :

php
// Empêcher l'invitation d'utilisateurs déjà enregistrés
if ($this->repo->findUserByEmail($email) !== null) {
    return $this->problems->create($request, 'conflict', 'Email already registered.', 409, '');
}

$expiresAt = (new \DateTimeImmutable())->modify('+24 hours')->format('Y-m-d H:i:s');
$token     = bin2hex(random_bytes(32));
$invite    = $this->repo->createInvitation($inviterId, $email, $token, $expiresAt, $now);

Retourner 409 lors de l'invitation d'un email enregistré révèle le statut d'enregistrement à l'invitant. C'est acceptable dans les systèmes sur invitation où les invitants sont des utilisateurs de confiance. Dans les systèmes entièrement publics, envisager d'unifier la réponse à 202.

Accepter une invitation ​

Vérifier l'expiration avant de vérifier le statut — une invitation en attente mais expirée doit retourner 410, pas 409 :

php
$invite = $this->repo->findByTokenOrNull($token);

if ($invite === null) {
    return $this->problems->create($request, 'not-found', 'Invitation not found.', 404, '');
}

$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');

if ($invite->isExpired($now)) {
    return $this->problems->create($request, 'gone', 'Invitation has expired.', 410, '');
}

if (!$invite->isPending()) {
    return $this->problems->create($request, 'conflict', 'Invitation is no longer valid.', 409, '');
}

isExpired compare directement les chaînes de timestamp actuelles — les chaînes datetime SQLite se trient lexicographiquement quand elles sont stockées comme Y-m-d H:i:s :

php
public function isExpired(string $now): bool
{
    return $now >= $this->expiresAt;
}

public function isPending(): bool
{
    return $this->status === 'pending';
}

Annuler une invitation ​

La propriété est appliquée en utilisant inviter_id depuis le corps de la requête (car il n'y a pas de middleware de session/JWT dans cet exemple minimal). En production, dériver l'acteur depuis un token authentifié à la place :

php
if ($invite->inviterId !== $inviterId) {
    return $this->problems->create($request, 'forbidden', 'Only the inviter may cancel this invitation.', 403, '');
}

if (!$invite->isPending()) {
    return $this->problems->create($request, 'conflict', 'Invitation is already ' . $invite->status . '.', 409, '');
}

Retourner 403 (pas 404) quand la vérification de propriété échoue — masquer l'existence de l'invitation cacherait le fait que l'attaquant a trouvé un vrai token, mais 403 est la sémantique correcte ici puisque la ressource a été trouvée mais l'action est interdite.

Machine d'états ​

pending ──accept──► accepted
pending ──cancel──► cancelled

Une fois qu'une invitation quitte pending, aucune transition ultérieure n'est autorisée. Tenter d'accepter une invitation accepted ou cancelled retourne 409.

Propriétés de sécurité ​

PropriétéImplémentation
Entropie du tokenbin2hex(random_bytes(32)) — 256 bits
Unicité du tokenContrainte UNIQUE sur invitations.token
Expiration à la lectureVérifiée dans le handler avant tout écriture
Prévention de réutilisationGarde isPending() avant accept/cancel
Application du propriétaireVérification d'égalité inviter_id → 403
Pas de fuite de PII emailLe corps 409 n'expose pas l'email invité
Injection SQLRequêtes PDO paramétrées partout

Résumé des routes ​

MéthodeCheminDescription
POST/usersCréer un compte utilisateur
POST/users/{id}/invitationsEnvoyer une invitation
GET/invitations/{token}Consulter une invitation
POST/invitations/{token}/acceptAccepter une invitation
DELETE/invitations/{token}Annuler une invitation

Publié sous licence MIT.