feat: Activation de compte utilisateur avec validation token
L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Cache;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* Cache-based UserRepository for development and testing.
|
||||
* Uses PSR-6 cache (filesystem in dev, Redis in prod).
|
||||
*
|
||||
* Note: Uses a dedicated users.cache pool with no TTL to ensure
|
||||
* user records don't expire (unlike activation tokens which expire after 7 days).
|
||||
*/
|
||||
final readonly class CacheUserRepository implements UserRepository
|
||||
{
|
||||
private const string KEY_PREFIX = 'user:';
|
||||
private const string EMAIL_INDEX_PREFIX = 'user_email:';
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $usersCache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function save(User $user): void
|
||||
{
|
||||
// Save user data
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
|
||||
$item->set($this->serialize($user));
|
||||
$this->usersCache->save($item);
|
||||
|
||||
// Save email index for lookup
|
||||
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email));
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
}
|
||||
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
|
||||
public function findByEmail(Email $email): ?User
|
||||
{
|
||||
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email));
|
||||
|
||||
if (!$emailItem->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string $userId */
|
||||
$userId = $emailItem->get();
|
||||
|
||||
return $this->findById(UserId::fromString($userId));
|
||||
}
|
||||
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->findById($id);
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serialize(User $user): array
|
||||
{
|
||||
$consentement = $user->consentementParental;
|
||||
|
||||
return [
|
||||
'id' => (string) $user->id,
|
||||
'email' => (string) $user->email,
|
||||
'role' => $user->role->value,
|
||||
'tenant_id' => (string) $user->tenantId,
|
||||
'school_name' => $user->schoolName,
|
||||
'statut' => $user->statut->value,
|
||||
'hashed_password' => $user->hashedPassword,
|
||||
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
|
||||
'created_at' => $user->createdAt->format('c'),
|
||||
'activated_at' => $user->activatedAt?->format('c'),
|
||||
'consentement_parental' => $consentement !== null ? [
|
||||
'parent_id' => $consentement->parentId,
|
||||
'eleve_id' => $consentement->eleveId,
|
||||
'date_consentement' => $consentement->dateConsentement->format('c'),
|
||||
'ip_address' => $consentement->ipAddress,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* id: string,
|
||||
* email: string,
|
||||
* role: string,
|
||||
* tenant_id: string,
|
||||
* school_name: string,
|
||||
* statut: string,
|
||||
* hashed_password: string|null,
|
||||
* date_naissance: string|null,
|
||||
* created_at: string,
|
||||
* activated_at: string|null,
|
||||
* consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null
|
||||
* } $data
|
||||
*/
|
||||
private function deserialize(array $data): User
|
||||
{
|
||||
$consentement = null;
|
||||
if ($data['consentement_parental'] !== null) {
|
||||
$consentementData = $data['consentement_parental'];
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: $consentementData['parent_id'],
|
||||
eleveId: $consentementData['eleve_id'],
|
||||
at: new DateTimeImmutable($consentementData['date_consentement']),
|
||||
ipAddress: $consentementData['ip_address'],
|
||||
);
|
||||
}
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($data['id']),
|
||||
email: new Email($data['email']),
|
||||
role: Role::from($data['role']),
|
||||
tenantId: TenantId::fromString($data['tenant_id']),
|
||||
schoolName: $data['school_name'],
|
||||
statut: StatutCompte::from($data['statut']),
|
||||
dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null,
|
||||
createdAt: new DateTimeImmutable($data['created_at']),
|
||||
hashedPassword: $data['hashed_password'],
|
||||
activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null,
|
||||
consentementParental: $consentement,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeEmail(Email $email): string
|
||||
{
|
||||
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use Override;
|
||||
|
||||
final class InMemoryActivationTokenRepository implements ActivationTokenRepository
|
||||
{
|
||||
/** @var array<string, ActivationToken> Indexed by token value */
|
||||
private array $byTokenValue = [];
|
||||
|
||||
/** @var array<string, string> Maps ID to token value */
|
||||
private array $idToTokenValue = [];
|
||||
|
||||
#[Override]
|
||||
public function save(ActivationToken $token): void
|
||||
{
|
||||
$this->byTokenValue[$token->tokenValue] = $token;
|
||||
$this->idToTokenValue[(string) $token->id] = $token->tokenValue;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTokenValue(string $tokenValue): ?ActivationToken
|
||||
{
|
||||
return $this->byTokenValue[$tokenValue] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ActivationTokenId $id): ActivationToken
|
||||
{
|
||||
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
|
||||
|
||||
if ($tokenValue === null) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
$token = $this->byTokenValue[$tokenValue] ?? null;
|
||||
|
||||
if ($token === null) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ActivationTokenId $id): void
|
||||
{
|
||||
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
|
||||
|
||||
if ($tokenValue !== null) {
|
||||
unset($this->byTokenValue[$tokenValue]);
|
||||
}
|
||||
|
||||
unset($this->idToTokenValue[(string) $id]);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function deleteByTokenValue(string $tokenValue): void
|
||||
{
|
||||
$token = $this->byTokenValue[$tokenValue] ?? null;
|
||||
|
||||
if ($token !== null) {
|
||||
unset($this->idToTokenValue[(string) $token->id]);
|
||||
}
|
||||
|
||||
unset($this->byTokenValue[$tokenValue]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use Override;
|
||||
|
||||
final class InMemoryUserRepository implements UserRepository
|
||||
{
|
||||
/** @var array<string, User> Indexed by ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, User> Indexed by email (lowercase) */
|
||||
private array $byEmail = [];
|
||||
|
||||
#[Override]
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->byId[(string) $user->id] = $user;
|
||||
$this->byEmail[strtolower((string) $user->email)] = $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByEmail(Email $email): ?User
|
||||
{
|
||||
return $this->byEmail[strtolower((string) $email)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Redis;
|
||||
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
final readonly class RedisActivationTokenRepository implements ActivationTokenRepository
|
||||
{
|
||||
private const string KEY_PREFIX = 'activation:';
|
||||
private const int TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $activationTokensCache,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(ActivationToken $token): void
|
||||
{
|
||||
// Store by token value for lookup during activation
|
||||
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
|
||||
$item->set($this->serialize($token));
|
||||
$item->expiresAfter(self::TTL_SECONDS);
|
||||
$this->activationTokensCache->save($item);
|
||||
|
||||
// Also store by ID for direct access
|
||||
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
|
||||
$idItem->set($token->tokenValue);
|
||||
$idItem->expiresAfter(self::TTL_SECONDS);
|
||||
$this->activationTokensCache->save($idItem);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTokenValue(string $tokenValue): ?ActivationToken
|
||||
{
|
||||
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $tokenValue);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ActivationTokenId $id): ActivationToken
|
||||
{
|
||||
// First get the token value from the ID index
|
||||
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
|
||||
|
||||
if (!$idItem->isHit()) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
/** @var string $tokenValue */
|
||||
$tokenValue = $idItem->get();
|
||||
$token = $this->findByTokenValue($tokenValue);
|
||||
|
||||
if ($token === null) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ActivationTokenId $id): void
|
||||
{
|
||||
// Get token value first
|
||||
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
|
||||
|
||||
if ($idItem->isHit()) {
|
||||
/** @var string $tokenValue */
|
||||
$tokenValue = $idItem->get();
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
|
||||
}
|
||||
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function deleteByTokenValue(string $tokenValue): void
|
||||
{
|
||||
$token = $this->findByTokenValue($tokenValue);
|
||||
|
||||
if ($token !== null) {
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
|
||||
}
|
||||
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null}
|
||||
*/
|
||||
private function serialize(ActivationToken $token): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $token->id,
|
||||
'token_value' => $token->tokenValue,
|
||||
'user_id' => $token->userId,
|
||||
'email' => $token->email,
|
||||
'tenant_id' => (string) $token->tenantId,
|
||||
'role' => $token->role,
|
||||
'school_name' => $token->schoolName,
|
||||
'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
|
||||
'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data
|
||||
*/
|
||||
private function deserialize(array $data): ActivationToken
|
||||
{
|
||||
return ActivationToken::reconstitute(
|
||||
id: ActivationTokenId::fromString($data['id']),
|
||||
tokenValue: $data['token_value'],
|
||||
userId: $data['user_id'],
|
||||
email: $data['email'],
|
||||
tenantId: TenantId::fromString($data['tenant_id']),
|
||||
role: $data['role'],
|
||||
schoolName: $data['school_name'],
|
||||
createdAt: new DateTimeImmutable($data['created_at']),
|
||||
expiresAt: new DateTimeImmutable($data['expires_at']),
|
||||
usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user