mirror of
https://github.com/bitwarden/server.git
synced 2026-04-22 21:34:43 -05:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.UserFeatures.Devices.Interfaces;
|
||||
|
||||
public interface IDeviceLastActivityCacheService
|
||||
{
|
||||
Task<bool> HasBeenBumpedTodayAsync(string identifier);
|
||||
Task RecordBumpAsync(string identifier);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user