PM-4517 - Add BumpDeviceLastActivityDateCommand with distributed cache guard

Adds IBumpDeviceLastActivityDateCommand and IDeviceLastActivityCacheService
interfaces with their implementations. The cache service uses the persistent
keyed IDistributedCache (Cosmos DB in cloud, SQL Server in self-hosted) with
a 48h TTL to guard against redundant DB writes within the same calendar day.
Moves device DI registration into a consolidated AddDeviceServices() extension.
This commit is contained in:
Jared Snider
2026-03-24 18:15:20 -04:00
parent c63208be5d
commit 60d1929585
8 changed files with 312 additions and 7 deletions

View File

@@ -0,0 +1,40 @@
using Bit.Core.Auth.UserFeatures.Devices.Interfaces;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.UserFeatures.Devices;
public class BumpDeviceLastActivityDateCommand : IBumpDeviceLastActivityDateCommand
{
private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceLastActivityCacheService _activityCache;
public BumpDeviceLastActivityDateCommand(
IDeviceRepository deviceRepository,
IDeviceLastActivityCacheService activityCache)
{
_deviceRepository = deviceRepository;
_activityCache = activityCache;
}
public async Task BumpByIdAsync(Guid deviceId, string identifier)
{
if (await _activityCache.HasBeenBumpedTodayAsync(identifier))
{
return;
}
await _deviceRepository.BumpLastActivityDateByIdAsync(deviceId);
await _activityCache.RecordBumpAsync(identifier);
}
public async Task BumpByIdentifierAsync(string identifier, Guid userId)
{
if (await _activityCache.HasBeenBumpedTodayAsync(identifier))
{
return;
}
await _deviceRepository.BumpLastActivityDateByIdentifierAsync(identifier, userId);
await _activityCache.RecordBumpAsync(identifier);
}
}

View File

@@ -0,0 +1,47 @@
using System.Text;
using Bit.Core.Auth.UserFeatures.Devices.Interfaces;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.UserFeatures.Devices;
public class DeviceLastActivityCacheService : IDeviceLastActivityCacheService
{
// TTL is 48h rather than 24h to ensure the entry outlives the full following calendar day
// regardless of bump time. A bump at 11:59 PM with a 24h TTL would expire mid-day tomorrow,
// creating a race window where a cache miss could trigger a redundant DB write on the same day.
// The date comparison in HasBeenBumpedTodayAsync is the real guard; TTL is housekeeping only.
private static readonly DistributedCacheEntryOptions _cacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(48)
};
private readonly IDistributedCache _cache;
private readonly TimeProvider _timeProvider;
public DeviceLastActivityCacheService(
// "persistent" is a well-known keyed service registered by AddDistributedCache(globalSettings).
// Backed by Cosmos DB in cloud; falls back to SQL Server/EF cache in self-hosted.
[FromKeyedServices("persistent")] IDistributedCache cache,
TimeProvider timeProvider)
{
_cache = cache;
_timeProvider = timeProvider;
}
public async Task<bool> HasBeenBumpedTodayAsync(string identifier)
{
var bytes = await _cache.GetAsync(CacheKey(identifier));
if (bytes == null) return false;
var cached = Encoding.UTF8.GetString(bytes);
return cached == _timeProvider.GetUtcNow().UtcDateTime.Date.ToString("yyyy-MM-dd");
}
public async Task RecordBumpAsync(string identifier)
{
var value = Encoding.UTF8.GetBytes(_timeProvider.GetUtcNow().UtcDateTime.Date.ToString("yyyy-MM-dd"));
await _cache.SetAsync(CacheKey(identifier), value, _cacheOptions);
}
private static string CacheKey(string identifier) => $"device:last-activity:{identifier}";
}

View File

@@ -0,0 +1,17 @@
using Bit.Core.Auth.UserFeatures.Devices.Interfaces;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Core.Auth.UserFeatures.Devices;
public static class DeviceServiceCollectionExtensions
{
public static void AddDeviceServices(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddScoped<IDeviceLastActivityCacheService, DeviceLastActivityCacheService>();
services.TryAddScoped<IUntrustDevicesCommand, UntrustDevicesCommand>();
services.TryAddScoped<IBumpDeviceLastActivityDateCommand, BumpDeviceLastActivityDateCommand>();
}
}

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Auth.UserFeatures.Devices.Interfaces;
public interface IBumpDeviceLastActivityDateCommand
{
Task BumpByIdAsync(Guid deviceId, string identifier);
Task BumpByIdentifierAsync(string identifier, Guid userId);
}

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Auth.UserFeatures.Devices.Interfaces;
public interface IDeviceLastActivityCacheService
{
Task<bool> HasBeenBumpedTodayAsync(string identifier);
Task RecordBumpAsync(string identifier);
}

View File

@@ -1,5 +1,5 @@
using Bit.Core.Auth.Sso;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Auth.UserFeatures.Devices;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
using Bit.Core.Auth.UserFeatures.Registration;
@@ -25,7 +25,7 @@ public static class UserServiceCollectionExtensions
public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings)
{
services.AddScoped<IUserService, UserService>();
services.AddDeviceTrustCommands();
services.AddDeviceServices();
services.AddEmergencyAccessCommands();
services.AddUserPasswordCommands();
services.AddUserRegistrationCommands();
@@ -35,11 +35,6 @@ public static class UserServiceCollectionExtensions
services.AddSsoQueries();
}
public static void AddDeviceTrustCommands(this IServiceCollection services)
{
services.AddScoped<IUntrustDevicesCommand, UntrustDevicesCommand>();
}
private static void AddEmergencyAccessCommands(this IServiceCollection services)
{
services.AddScoped<IDeleteEmergencyAccessCommand, DeleteEmergencyAccessCommand>();

View File

@@ -0,0 +1,93 @@
using Bit.Core.Auth.UserFeatures.Devices;
using Bit.Core.Auth.UserFeatures.Devices.Interfaces;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Auth.UserFeatures.Devices;
[SutProviderCustomize]
public class BumpDeviceLastActivityDateCommandTests
{
[Theory, BitAutoData]
public async Task BumpByIdAsync_GivenCacheHit_DoesNotCallRepository(
SutProvider<BumpDeviceLastActivityDateCommand> sutProvider,
Guid deviceId,
string identifier)
{
sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.HasBeenBumpedTodayAsync(identifier)
.Returns(true);
await sutProvider.Sut.BumpByIdAsync(deviceId, identifier);
await sutProvider.GetDependency<IDeviceRepository>()
.DidNotReceive()
.BumpLastActivityDateByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.DidNotReceive()
.RecordBumpAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task BumpByIdAsync_GivenCacheMiss_CallsRepositoryAndRecordsCache(
SutProvider<BumpDeviceLastActivityDateCommand> sutProvider,
Guid deviceId,
string identifier)
{
sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.HasBeenBumpedTodayAsync(identifier)
.Returns(false);
await sutProvider.Sut.BumpByIdAsync(deviceId, identifier);
await sutProvider.GetDependency<IDeviceRepository>()
.Received(1)
.BumpLastActivityDateByIdAsync(deviceId);
await sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.Received(1)
.RecordBumpAsync(identifier);
}
[Theory, BitAutoData]
public async Task BumpByIdentifierAsync_GivenCacheHit_DoesNotCallRepository(
SutProvider<BumpDeviceLastActivityDateCommand> sutProvider,
string identifier,
Guid userId)
{
sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.HasBeenBumpedTodayAsync(identifier)
.Returns(true);
await sutProvider.Sut.BumpByIdentifierAsync(identifier, userId);
await sutProvider.GetDependency<IDeviceRepository>()
.DidNotReceive()
.BumpLastActivityDateByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>());
await sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.DidNotReceive()
.RecordBumpAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task BumpByIdentifierAsync_GivenCacheMiss_CallsRepositoryAndRecordsCache(
SutProvider<BumpDeviceLastActivityDateCommand> sutProvider,
string identifier,
Guid userId)
{
sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.HasBeenBumpedTodayAsync(identifier)
.Returns(false);
await sutProvider.Sut.BumpByIdentifierAsync(identifier, userId);
await sutProvider.GetDependency<IDeviceRepository>()
.Received(1)
.BumpLastActivityDateByIdentifierAsync(identifier, userId);
await sutProvider.GetDependency<IDeviceLastActivityCacheService>()
.Received(1)
.RecordBumpAsync(identifier);
}
}

View File

@@ -0,0 +1,99 @@
using System.Text;
using Bit.Core.Auth.UserFeatures.Devices;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Auth.UserFeatures.Devices;
[SutProviderCustomize]
public class DeviceLastActivityCacheServiceTests
{
[Theory, BitAutoData]
public async Task HasBeenBumpedTodayAsync_GivenCachedDateIsToday_ReturnsTrue(string identifier)
{
var sutProvider = new SutProvider<DeviceLastActivityCacheService>()
.WithFakeTimeProvider()
.Create();
var today = sutProvider.GetDependency<FakeTimeProvider>().GetUtcNow().UtcDateTime.Date.ToString("yyyy-MM-dd");
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Encoding.UTF8.GetBytes(today));
var result = await sutProvider.Sut.HasBeenBumpedTodayAsync(identifier);
Assert.True(result);
}
[Theory, BitAutoData]
public async Task HasBeenBumpedTodayAsync_GivenCachedDateIsYesterday_ReturnsFalse(string identifier)
{
var sutProvider = new SutProvider<DeviceLastActivityCacheService>()
.WithFakeTimeProvider()
.Create();
var yesterday = sutProvider.GetDependency<FakeTimeProvider>().GetUtcNow().UtcDateTime.Date.AddDays(-1).ToString("yyyy-MM-dd");
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Encoding.UTF8.GetBytes(yesterday));
var result = await sutProvider.Sut.HasBeenBumpedTodayAsync(identifier);
Assert.False(result);
}
[Theory, BitAutoData]
public async Task HasBeenBumpedTodayAsync_GivenCacheMiss_ReturnsFalse(
SutProvider<DeviceLastActivityCacheService> sutProvider,
string identifier)
{
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((byte[])null);
var result = await sutProvider.Sut.HasBeenBumpedTodayAsync(identifier);
Assert.False(result);
}
[Theory, BitAutoData]
public async Task RecordBumpAsync_StoresCorrectDateAndTtl(string identifier)
{
var sutProvider = new SutProvider<DeviceLastActivityCacheService>()
.WithFakeTimeProvider()
.Create();
var today = sutProvider.GetDependency<FakeTimeProvider>().GetUtcNow().UtcDateTime.Date.ToString("yyyy-MM-dd");
await sutProvider.Sut.RecordBumpAsync(identifier);
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.SetAsync(
Arg.Any<string>(),
Arg.Is<byte[]>(b => Encoding.UTF8.GetString(b) == today),
Arg.Is<DistributedCacheEntryOptions>(o => o.AbsoluteExpirationRelativeToNow == TimeSpan.FromHours(48)),
Arg.Any<CancellationToken>());
}
[Theory, BitAutoData]
public async Task RecordBumpAsync_UsesCacheKeyFormat(
SutProvider<DeviceLastActivityCacheService> sutProvider)
{
var identifier = "my-device-id";
await sutProvider.Sut.RecordBumpAsync(identifier);
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.SetAsync(
"device:last-activity:my-device-id",
Arg.Any<byte[]>(),
Arg.Any<DistributedCacheEntryOptions>(),
Arg.Any<CancellationToken>());
}
}