НейроАгент

Полное руководство по недействительности IMemoryCache для безопасности устройств

Устраните 2-минутную уязвимость безопасности в управлении устройствами с помощью немедленных методов недействительности IMemoryCache. Всеобъемлющее руководство с примерами реализации.

Как немедленно аннулировать IMemoryCache при отзыве подключенных устройств, чтобы предотвратить 2-минутное окно доступа?

Я создаю DeviceService для отслеживания подключенных устройств для аккаунта. Я реализовал IMemoryCache для уменьшения обращений к базе данных, устанавливая кэш статуса валидности на две минуты. Однако это создает проблему: при отзыве подключенного устройства оно остается доступным в течение двух минут после отзыва до следующего запроса.

Я понимаю, что этот подход снижает нагрузку на базу данных, но он дает отозванным устройствам 2-минутное окно, в течение которого они все еще могут получить доступ к аккаунту. Как правильно обрабатывать эту ситуацию? Правильна ли моя текущая реализация, или существует лучший метод для немедленной аннулировки кэша при отзыве устройств?

НейроАгент

Мгновенная аннулирование IMemoryCache при отзыве подключенных устройств может быть достигнуто с помощью обратных вызовов удаления записей кэша, распределенных шаблонов кэширования или систем уведомлений, управляемых событиями. Двухминутное окно доступа можно устранить, реализуя активное аннулирование кэша вместо того, чтобы полагаться исключительно на пассивное истечение срока действия, обеспечивая немедленную потерю доступа для отозванных устройств.

Содержание

Понимание текущей реализации кэша

Ваш текущий подход с использованием IMemoryCache с двухминутным временем истечения срока действия является распространенным для снижения нагрузки на базу данных, но, как вы обнаружили, он создает риск безопасности при отзыве устройства. Кэш памяти в ASP.NET Core работает путем хранения данных со временем истечения срока действия, но он не предоставляет возможности немедленного аннулирования из коробки.

Типичная реализация может выглядеть так:

csharp
public class DeviceService
{
    private readonly IMemoryCache _cache;
    private readonly IDeviceRepository _deviceRepository;
    
    public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository)
    {
        _cache = cache;
        _deviceRepository = deviceRepository;
    }
    
    public async Task<bool> IsDeviceValidAsync(string deviceId)
    {
        string cacheKey = $"device_status_{deviceId}";
        
        if (_cache.TryGetValue(cacheKey, out bool isValid))
        {
            return isValid;
        }
        
        isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
        _cache.Set(cacheKey, isValid, TimeSpan.FromMinutes(2));
        
        return isValid;
    }
}

Этот подход хорошо работает для производительности, но оставляет уязвимость в 2 минуты, которую вы описали. Ключевая проблема заключается в том, что запись кэша остается действительной до тех пор, пока:

  1. Не истечет 2-минутный срок действия, или
  2. То же устройство не сделает другой запрос (обновляя кэш)

Стратегии мгновенного аннулирования кэша

1. Использование обратных вызовов удаления записей кэша

Наиболее прямой подход - использование обратных вызовов кэша памяти для отслеживания активных записей кэша и их ручного аннулирования. Это обеспечивает немедленный контроль над записями кэша.

csharp
public class DeviceService
{
    private readonly IMemoryCache _cache;
    private readonly IDeviceRepository _deviceRepository;
    private readonly ConcurrentDictionary<string, CancellationTokenSource> _cacheEntries;
    
    public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository)
    {
        _cache = cache;
        _deviceRepository = deviceRepository;
        _cacheEntries = new ConcurrentDictionary<string, CancellationTokenSource>();
    }
    
    public async Task<bool> IsDeviceValidAsync(string deviceId)
    {
        string cacheKey = $"device_status_{deviceId}";
        
        if (_cache.TryGetValue(cacheKey, out bool isValid))
        {
            return isValid;
        }
        
        var cts = new CancellationTokenSource();
        _cacheEntries.TryAdd(cacheKey, cts);
        
        isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
        _cache.Set(cacheKey, isValid, 
            new MemoryCacheEntryOptions
            {
                PostEvictionCallbacks = 
                {
                    new PostEvictionCallbackRegistration
                    {
                        EvictionCallback = RemoveCacheEntry,
                        State = cacheKey
                    }
                }
            });
        
        return isValid;
    }
    
    public void RevokeDevice(string deviceId)
    {
        string cacheKey = $"device_status_{deviceId}";
        
        if (_cacheEntries.TryRemove(cacheKey, out var cts))
        {
            cts.Cancel();
            _cache.Remove(cacheKey);
        }
    }
    
    private void RemoveCacheEntry(object key, object value, EvictionReason reason, object state)
    {
        if (state is string cacheKey && _cacheEntries.TryRemove(cacheKey, out var cts))
        {
            cts.Dispose();
        }
    }
}

2. Использование MemoryCacheEntryOptions с токенами истечения срока действия

Другой подход - использование IChangeToken для создания механизмов пользовательского истечения срока действия:

csharp
public class DeviceRevocationToken : IChangeToken
{
    private readonly CancellationTokenSource _cts;
    
    public DeviceRevocationToken()
    {
        _cts = new CancellationTokenSource();
    }
    
    public bool ActiveChangeCallbacks => true;
    public bool HasChanged => false; // Мы контролируем это вручную
    
    public IDisposable RegisterChangeCallback(Action<object> callback, object state)
    {
        return _cts.Token.Register(callback, state);
    }
    
    public void TriggerRevocation()
    {
        _cts.Cancel();
    }
    
    public void Dispose()
    {
        _cts.Dispose();
    }
}

public class DeviceService
{
    private readonly IMemoryCache _cache;
    private readonly IDeviceRepository _deviceRepository;
    
    public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository)
    {
        _cache = cache;
        _deviceRepository = deviceRepository;
    }
    
    public async Task<bool> IsDeviceValidAsync(string deviceId)
    {
        string cacheKey = $"device_status_{deviceId}";
        
        if (_cache.TryGetValue(cacheKey, out bool isValid))
        {
            return isValid;
        }
        
        var revocationToken = new DeviceRevocationToken();
        isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
        
        _cache.Set(cacheKey, isValid, 
            new MemoryCacheEntryOptions
            {
                ExpirationTokens = { revocationToken }
            });
        
        return isValid;
    }
    
    public void RevokeDevice(string deviceId)
    {
        string cacheKey = $"device_status_{deviceId}";
        _cache.Remove(cacheKey);
    }
}

Реализация аннулирования кэша, управляемого событиями

Для более сложных сценариев рассмотрите возможность реализации управляемого событиями подхода, при котором отзыв устройств запускает аннулирование кэша во всем приложении:

1. Использование MediatR или шаблона шины событий

csharp
public class DeviceRevokedEvent : INotification
{
    public string DeviceId { get; }
    public DateTime RevokedAt { get; }
    
    public DeviceRevokedEvent(string deviceId)
    {
        DeviceId = deviceId;
        RevokedAt = DateTime.UtcNow;
    }
}

public class DeviceRevocationHandler : INotificationHandler<DeviceRevokedEvent>
{
    private readonly IMemoryCache _cache;
    
    public DeviceRevocationHandler(IMemoryCache cache)
    {
        _cache = cache;
    }
    
    public Task Handle(DeviceRevokedEvent notification, CancellationToken cancellationToken)
    {
        string cacheKey = $"device_status_{notification.DeviceId}";
        _cache.Remove(cacheKey);
        
        return Task.CompletedTask;
    }
}

public class DeviceService
{
    private readonly IMemoryCache _cache;
    private readonly IDeviceRepository _deviceRepository;
    private readonly IServiceProvider _serviceProvider;
    
    public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository, IServiceProvider serviceProvider)
    {
        _cache = cache;
        _deviceRepository = deviceRepository;
        _serviceProvider = serviceProvider;
    }
    
    public async Task<bool> IsDeviceValidAsync(string deviceId)
    {
        string cacheKey = $"device_status_{deviceId}";
        
        if (_cache.TryGetValue(cacheKey, out bool isValid))
        {
            return isValid;
        }
        
        isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
        _cache.Set(cacheKey, isValid, TimeSpan.FromMinutes(2));
        
        return isValid;
    }
    
    public async Task RevokeDeviceAsync(string deviceId)
    {
        // Сначала отозвать в базе данных
        await _deviceRepository.RevokeDeviceAsync(deviceId);
        
        // Запустить аннулирование кэша
        using var scope = _serviceProvider.CreateScope();
        var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
        await mediator.Publish(new DeviceRevokedEvent(deviceId));
    }
}

2. Фоновая служба для синхронизации кэша

Если вам нужно обрабатывать аннулирование кэша на нескольких экземплярах:

csharp
public class CacheInvalidationBackgroundService : BackgroundService
{
    private readonly IMemoryCache _cache;
    private readonly ICacheInvalidationQueue _queue;
    
    public CacheInvalidationBackgroundService(IMemoryCache cache, ICacheInvalidationQueue queue)
    {
        _cache = cache;
        _queue = queue;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var invalidationRequest = await _queue.DequeueAsync(stoppingToken);
            _cache.Remove(invalidationRequest.CacheKey);
        }
    }
}

public interface ICacheInvalidationQueue
{
    Task EnqueueAsync(string cacheKey);
    Task<string> DequeueAsync(CancellationToken cancellationToken);
}

public interface ICacheInvalidationPublisher
{
    Task PublishInvalidationAsync(string cacheKey);
}

public class RedisCacheInvalidationPublisher : ICacheInvalidationPublisher
{
    private readonly IConnectionMultiplexer _redis;
    private readonly IDatabase _database;
    private readonly string _channel = "cache_invalidation";
    
    public RedisCacheInvalidationPublisher(IConnectionMultiplexer redis)
    {
        _redis = redis;
        _database = redis.GetDatabase();
    }
    
    public async Task PublishInvalidationAsync(string cacheKey)
    {
        await _database.PublishAsync(_channel, cacheKey);
    }
}

Распределенные шаблоны кэширования для многсерверных сред

При работе с несколькими серверными экземплярами вам нужен распределенный подход к аннулированию кэша:

1. Использование Redis для распределенного аннулирования кэша

csharp
public class RedisCacheInvalidationSubscriber : BackgroundService
{
    private readonly IMemoryCache _memoryCache;
    private readonly IConnectionMultiplexer _redis;
    private readonly string _channel = "cache_invalidation";
    
    public RedisCacheInvalidationSubscriber(IMemoryCache memoryCache, IConnectionMultiplexer redis)
    {
        _memoryCache = memoryCache;
        _redis = redis;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var subscriber = _redis.GetSubscriber();
        
        await subscriber.SubscribeAsync(_channel, (channel, message) =>
        {
            if (message.HasValue)
            {
                string cacheKey = message.ToString();
                _memoryCache.Remove(cacheKey);
            }
        }, CommandFlags.FireAndForget);
        
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }
}

// В Startup.cs:
services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});

services.AddSingleton<IHostedService, RedisCacheInvalidationSubscriber>();

2. Использование SQL Server для аннулирования кэша

csharp
public class SqlCacheInvalidationService : BackgroundService
{
    private readonly IMemoryCache _memoryCache;
    private readonly IConfiguration _configuration;
    private Timer _timer;
    
    public SqlCacheInvalidationService(IMemoryCache memoryCache, IConfiguration configuration)
    {
        _memoryCache = memoryCache;
        _configuration = configuration;
    }
    
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _timer = new Timer(CheckForInvalidations, null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
        return Task.CompletedTask;
    }
    
    private void CheckForInvalidations(object state)
    {
        try
        {
            using var connection = new SqlConnection(_configuration.GetConnectionString("DefaultConnection"));
            connection.Open();
            
            using var command = new SqlCommand(
                "SELECT CacheKey FROM CacheInvalidations WHERE Processed = 0", connection);
            
            using var reader = command.ExecuteReader();
            while (reader.Read())
            {
                string cacheKey = reader["CacheKey"].ToString();
                _memoryCache.Remove(cacheKey);
            }
            
            // Отметить аннулирования как обработанные
            command.CommandText = "UPDATE CacheInvalidations SET Processed = 1 WHERE Processed = 0";
            command.ExecuteNonQuery();
        }
        catch (Exception ex)
        {
            // Логировать ошибку
        }
    }
    
    public override void Dispose()
    {
        _timer?.Dispose();
        base.Dispose();
    }
}

Лучшие практики и соображения по производительности

Управление ключами кэша

  • Используйте последовательные, описательные ключи кэша
  • Учитывайте контекст пользователя/аккаунта в ключах кэша
  • Реализуйте правильные соглашения об именовании ключей

Управление памятью

  • Мониторьте использование памяти кэша
  • Реализуйте ограничения размера кэша
  • Рассмотрите использование MemoryCacheEntryOptions.SizeLimit для больших объектов

Соображения параллелизма

  • Используйте потокобезопасные коллекции для отслеживания записей кэша
  • Реализуйте соответствующие механизмы блокировки при необходимости
  • Рассмотрите использование ConcurrentDictionary для отслеживания записей кэша

Оптимизация производительности

  • Минимизируйте накладные расходы на аннулирование кэша
  • По возможности выполняйте пакетное аннулирование кэша
  • Рассмотрите использование async/await для операций с кэшем

Соображения безопасности

  • Убедитесь, что аннулирование кэша не может быть запущено неавторизованными пользователями
  • Реализуйте соответствующие механизмы контроля доступа для отзыва устройств
  • Рассмотрите ведение журнала аудита для событий аннулирования кэша

Полный пример реализации

Вот полный, готовый к производству пример реализации, который объединяет несколько обсужденных стратегий:

csharp
public interface IDeviceCacheService
{
    Task<bool> IsDeviceValidAsync(string deviceId);
    Task RevokeDeviceAsync(string deviceId);
    Task RevokeAllDevicesForAccountAsync(string accountId);
}

public class DeviceCacheService : IDeviceCacheService
{
    private readonly IMemoryCache _cache;
    private readonly IDeviceRepository _deviceRepository;
    private readonly ICacheInvalidationPublisher _invalidationPublisher;
    private readonly ILogger<DeviceCacheService> _logger;
    private readonly ConcurrentDictionary<string, DeviceCacheEntry> _activeEntries;
    
    public DeviceCacheService(
        IMemoryCache cache,
        IDeviceRepository deviceRepository,
        ICacheInvalidationPublisher invalidationPublisher,
        ILogger<DeviceCacheService> logger)
    {
        _cache = cache;
        _deviceRepository = deviceRepository;
        _invalidationPublisher = invalidationPublisher;
        _logger = logger;
        _activeEntries = new ConcurrentDictionary<string, DeviceCacheEntry>();
    }
    
    public async Task<bool> IsDeviceValidAsync(string deviceId)
    {
        string cacheKey = GetDeviceCacheKey(deviceId);
        
        if (_cache.TryGetValue(cacheKey, out bool isValid))
        {
            return isValid;
        }
        
        var entry = new DeviceCacheEntry(deviceId);
        _activeEntries.TryAdd(cacheKey, entry);
        
        try
        {
            isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
            _cache.Set(cacheKey, isValid, 
                new MemoryCacheEntryOptions
                {
                    SlidingExpiration = TimeSpan.FromMinutes(2),
                    PostEvictionCallbacks = 
                    {
                        new PostEvictionCallbackRegistration
                        {
                            EvictionCallback = OnCacheEntryEvicted,
                            State = cacheKey
                        }
                    }
                });
            
            return isValid;
        }
        catch (Exception ex)
        {
            _activeEntries.TryRemove(cacheKey, out _);
            _logger.LogError(ex, "Ошибка проверки валидности устройства для устройства {DeviceId}", deviceId);
            throw;
        }
    }
    
    public async Task RevokeDeviceAsync(string deviceId)
    {
        string cacheKey = GetDeviceCacheKey(deviceId);
        
        // Немедленно удалить из локального кэша
        _cache.Remove(cacheKey);
        
        // Удалить из словаря отслеживания
        _activeEntries.TryRemove(cacheKey, out _);
        
        // Отозвать в базе данных
        await _deviceRepository.RevokeDeviceAsync(deviceId);
        
        // Опубликовать событие аннулирования (для распределенных сценариев)
        await _invalidationPublisher.PublishInvalidationAsync(cacheKey);
        
        _logger.LogInformation("Устройство {DeviceId} было отозвано", deviceId);
    }
    
    public async Task RevokeAllDevicesForAccountAsync(string accountId)
    {
        var deviceIds = await _deviceRepository.GetDeviceIdsForAccountAsync(accountId);
        
        foreach (var deviceId in deviceIds)
        {
            await RevokeDeviceAsync(deviceId);
        }
        
        _logger.LogInformation("Все устройства для аккаунта {AccountId} были отозваны", accountId);
    }
    
    private void OnCacheEntryEvicted(object key, object value, EvictionReason reason, object state)
    {
        if (state is string cacheKey)
        {
            _activeEntries.TryRemove(cacheKey, out _);
            
            if (reason == EvictionReason.Expired)
            {
                _logger.LogDebug("Запись кэша для {CacheKey} истекла", cacheKey);
            }
        }
    }
    
    private string GetDeviceCacheKey(string deviceId) => $"device_validity_{deviceId}";
}

public class DeviceCacheEntry
{
    public string DeviceId { get; }
    public DateTime CreatedAt { get; }
    public DateTime? LastAccessed { get; private set; }
    
    public DeviceCacheEntry(string deviceId)
    {
        DeviceId = deviceId;
        CreatedAt = DateTime.UtcNow;
        LastAccessed = DateTime.UtcNow;
    }
    
    public void UpdateAccessTime()
    {
        LastAccessed = DateTime.UtcNow;
    }
}

Альтернативные подходы и компромиссы

1. Более короткий срок действия кэша с устаревшим-пока-перезагружается

csharp
_cache.Set(cacheKey, isValid, 
    new MemoryCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromSeconds(30),
        PostEvictionCallbacks = 
        {
            new PostEvictionCallbackRegistration
            {
                EvictionCallback = (key, value, reason, state) =>
                {
                    if (reason == EvictionReason.Expired)
                    {
                        // Логика фонового обновления
                        Task.Run(() => RefreshCacheEntry(key.ToString()));
                    }
                },
                State = cacheKey
            }
        }
    });

Плюсы: Сокращенное окно для несанкционированного доступа
Минусы: Больше обращений к базе данных, потенциальная несогласованность состояния

2. Использование маркировки кэша

csharp
_cache.Set(cacheKey, isValid, 
    new MemoryCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(2),
        Tags = new[] { $"account_{accountId}", $"device_{deviceId}" }
    });

// Аннулировать все записи для аккаунта
_cache.RemoveByTag($"account_{accountId}");

Плюсы: Возможности пакетного аннулирования
Минусы: Требуется MemoryCache compatability pack, немного больше накладных расходов

3. Гибридный подход: В памяти + распределенный кэш

csharp
public class HybridDeviceCacheService
{
    private readonly IMemoryCache _localCache;
    private readonly IDistributedCache _distributedCache;
    private readonly IDeviceRepository _deviceRepository;
    
    public async Task<bool> IsDeviceValidAsync(string deviceId)
    {
        // Сначала попробовать локальный кэш
        if (_localCache.TryGetValue(deviceId, out bool isValid))
        {
            return isValid;
        }
        
        // Попробовать распределенный кэш
        var distributedValue = await _distributedCache.GetStringAsync(deviceId);
        if (distributedValue != null)
        {
            isValid = bool.Parse(distributedValue);
            _localCache.Set(deviceId, isValid, TimeSpan.FromMinutes(2));
            return isValid;
        }
        
        // Проверить базу данных
        isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
        await _distributedCache.SetStringAsync(deviceId, isValid.ToString(), 
            new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(2) });
        _localCache.Set(deviceId, isValid, TimeSpan.FromMinutes(2));
        
        return isValid;
    }
}

Плюсы: Хорошо подходит для развертываний на нескольких серверах, немедленное аннулирование возможно
Минусы: Более сложная реализация, внешняя зависимость

4. Использование выделенной службы кэширования

Рассмотрите возможность реализации выделенной службы кэширования, которая более полно управляет состоянием устройства:

cpublic class DeviceStateService
{
    private readonly ConcurrentDictionary<string, DeviceState> _deviceStates;
    private readonly Timer _cleanupTimer;
    
    public DeviceStateService()
    {
        _deviceStates = new ConcurrentDictionary<string, DeviceState>();
        _cleanupTimer = new Timer(CleanupExpiredDevices, null, 
            TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
    }
    
    public bool IsDeviceValid(string deviceId)
    {
        if (_deviceStates.TryGetValue(deviceId, out var state))
        {
            if (state.IsValid && !state.IsRevoked && !state.HasExpired)
            {
                return true;
            }
            
            // Удалить недействительное устройство
            _deviceStates.TryRemove(deviceId, out _);
        }
        
        return false;
    }
    
    public void RevokeDevice(string deviceId)
    {
        _deviceStates.AddOrUpdate(deviceId, 
            key => new DeviceState { IsRevoked = true },
            (key, existing) => { existing.IsRevoked = true; return existing; });
    }
    
    private void CleanupExpiredDevices(object state)
    {
        var expiredDevices = _deviceStates
            .Where(kv => kv.Value.HasExpired)
            .Select(kv => kv.Key)
            .ToList();
        
        foreach (var deviceId in expiredDevices)
        {
            _deviceStates.TryRemove(deviceId, out _);
        }
    }
    
    public void Dispose()
    {
        _cleanupTimer?.Dispose();
    }
}

public class DeviceState
{
    public bool IsValid { get; set; }
    public bool IsRevoked { get; set; }
    public DateTime LastValidated { get; set; }
    public DateTime ExpiresAt { get; set; }
    
    public bool HasExpired => DateTime.UtcNow > ExpiresAt;
}

Плюсы: Полный контроль над состоянием устройства, немедленное аннулирование
Минусы: Нет встроенных преимуществ кэширования, требуется ручное управление памятью

Заключение

Чтобы эффективно обрабатывать немедленное аннулирование кэша для отзыва устройств в ASP.NET Core, учтите эти ключевые моменты:

  1. Выберите правильную стратегию на основе среды развертывания - один сервер против нескольких серверов, требования к производительности и потребности в безопасности.

  2. Реализуйте управляемое событиями аннулирование с использованием обратных вызовов, токенов или шин событий для обеспечения немедленного удаления кэша при отзыве устройств.

  3. Рассмотрите распределенные решения, такие как Redis или SQL Server для аннулирования кэша, для развертываний на нескольких серверах для поддержания согласованности кэша между экземплярами.

  4. Сбалансируйте производительность и безопасность - хотя более короткие сроки действия кэша сокращают окна безопасности, они также увеличивают нагрузку на базу данных. Найдите оптимальный баланс для вашего приложения.

  5. Мониторьте и аудитируйте события аннулирования кэша, чтобы убедиться, что требования безопасности выполняются и отслеживать любые потенциальные проблемы.

Наиболее надежное решение обычно сочетает локальное аннулирование кэша с распределенными уведомлениями, обеспечивая немедленный отклик при сохранении хороших характеристик производительности. Ваш текущий подход не принципиально неверен, но добавление механизмов немедленного аннулирования устранит уязвимость безопасности, сохраняя при этом большинство преимуществ кэширования.

Источники

  1. Microsoft Docs - Memory cache in ASP.NET Core
  2. Microsoft Docs - Distributed caching in ASP.NET Core
  3. Stack Overflow - How to remove specific item from MemoryCache
  4. Microsoft Docs - Background tasks with hosted services in ASP.NET Core
  5. Microsoft Docs - PostEvictionCallbackRegistration