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.