refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id); $tokenItem->set($this->serialize($token)); // Calculer le TTL restant $now = new DateTimeImmutable(); $ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp(); if ($ttl > 0) { $tokenItem->expiresAfter($ttl); } $this->refreshTokensCache->save($tokenItem); // Ajouter à l'index famille // Ne jamais réduire le TTL de l'index famille // L'index doit survivre aussi longtemps que le token le plus récent de la famille $familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId); /** @var list $familyTokenIds */ $familyTokenIds = $familyItem->isHit() ? $familyItem->get() : []; $familyTokenIds[] = (string) $token->id; $familyItem->set(array_unique($familyTokenIds)); // Seulement étendre le TTL, jamais le réduire // Pour les tokens rotated (ancien), on ne change pas le TTL de l'index if (!$token->isRotated && $ttl > 0) { $familyItem->expiresAfter($ttl); } elseif (!$familyItem->isHit()) { // Nouveau index - définir le TTL initial $familyItem->expiresAfter($ttl > 0 ? $ttl : 604800); } // Si c'est un token rotaté et l'index existe déjà, on garde le TTL existant $this->refreshTokensCache->save($familyItem); } public function find(RefreshTokenId $id): ?RefreshToken { $item = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $id); if (!$item->isHit()) { return null; } /** @var array{id: string, family_id: string, user_id: string, tenant_id: string, device_fingerprint: string, issued_at: string, expires_at: string, rotated_from: string|null, is_rotated: bool, rotated_at?: string|null} $data */ $data = $item->get(); return $this->deserialize($data); } public function findByToken(string $tokenValue): ?RefreshToken { return $this->find(RefreshTokenId::fromString($tokenValue)); } public function delete(RefreshTokenId $id): void { $this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $id); } public function invalidateFamily(TokenFamilyId $familyId): void { $familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $familyId); if (!$familyItem->isHit()) { return; } /** @var list $tokenIds */ $tokenIds = $familyItem->get(); // Supprimer tous les tokens de la famille foreach ($tokenIds as $tokenId) { $this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId); } // Supprimer l'index famille $this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId); } /** * @return array */ private function serialize(RefreshToken $token): array { return [ 'id' => (string) $token->id, 'family_id' => (string) $token->familyId, 'user_id' => (string) $token->userId, 'tenant_id' => (string) $token->tenantId, 'device_fingerprint' => (string) $token->deviceFingerprint, 'issued_at' => $token->issuedAt->format(DateTimeInterface::ATOM), 'expires_at' => $token->expiresAt->format(DateTimeInterface::ATOM), 'rotated_from' => $token->rotatedFrom !== null ? (string) $token->rotatedFrom : null, 'is_rotated' => $token->isRotated, 'rotated_at' => $token->rotatedAt?->format(DateTimeInterface::ATOM), ]; } /** * @param array{ * id: string, * family_id: string, * user_id: string, * tenant_id: string, * device_fingerprint: string, * issued_at: string, * expires_at: string, * rotated_from: string|null, * is_rotated: bool, * rotated_at?: string|null * } $data */ private function deserialize(array $data): RefreshToken { $rotatedAt = $data['rotated_at'] ?? null; return RefreshToken::reconstitute( id: RefreshTokenId::fromString($data['id']), familyId: TokenFamilyId::fromString($data['family_id']), userId: UserId::fromString($data['user_id']), tenantId: TenantId::fromString($data['tenant_id']), deviceFingerprint: DeviceFingerprint::fromString($data['device_fingerprint']), issuedAt: new DateTimeImmutable($data['issued_at']), expiresAt: new DateTimeImmutable($data['expires_at']), rotatedFrom: $data['rotated_from'] !== null ? RefreshTokenId::fromString($data['rotated_from']) : null, isRotated: $data['is_rotated'], rotatedAt: $rotatedAt !== null ? new DateTimeImmutable($rotatedAt) : null, ); } }