diff --git a/src/Api/Platform/Installations/Controllers/InstallationsController.cs b/src/Api/Platform/Installations/Controllers/InstallationsController.cs index a9ba4e6c02b9..96cdc9d95cec 100644 --- a/src/Api/Platform/Installations/Controllers/InstallationsController.cs +++ b/src/Api/Platform/Installations/Controllers/InstallationsController.cs @@ -6,6 +6,15 @@ namespace Bit.Api.Platform.Installations; +/// +/// Routes used to manipulate `Installation` objects: a type used to manage +/// a record of a self hosted installation. +/// +/// +/// This controller is not called from any clients. It's primarily referenced +/// in the `Setup` project for creating a new self hosted installation. +/// +/// Bit.Setup.Program [Route("installations")] [SelfHosted(NotSelfHostedOnly = true)] public class InstallationsController : Controller diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0b7435cf8f32..830e3f65b221 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -165,6 +165,7 @@ public static class FeatureFlagKeys public const string AppReviewPrompt = "app-review-prompt"; public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; public const string UsePricingService = "use-pricing-service"; + public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; public static List GetAllKeys() { diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs new file mode 100644 index 000000000000..d0c25b96a455 --- /dev/null +++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Command interface responsible for updating data on an `Installation` +/// record. +/// +/// +/// This interface is implemented by `UpdateInstallationCommand` +/// +/// +public interface IUpdateInstallationCommand +{ + Task UpdateLastActivityDateAsync(Guid installationId); +} diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs new file mode 100644 index 000000000000..4b0bc3bbe804 --- /dev/null +++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs @@ -0,0 +1,53 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Commands responsible for updating an installation from +/// `InstallationRepository`. +/// +/// +/// If referencing: you probably want the interface +/// `IUpdateInstallationCommand` instead of directly calling this class. +/// +/// +public class UpdateInstallationCommand : IUpdateInstallationCommand +{ + private readonly IGetInstallationQuery _getInstallationQuery; + private readonly IInstallationRepository _installationRepository; + private readonly TimeProvider _timeProvider; + + public UpdateInstallationCommand( + IGetInstallationQuery getInstallationQuery, + IInstallationRepository installationRepository, + TimeProvider timeProvider + ) + { + _getInstallationQuery = getInstallationQuery; + _installationRepository = installationRepository; + _timeProvider = timeProvider; + } + + public async Task UpdateLastActivityDateAsync(Guid installationId) + { + if (installationId == default) + { + throw new Exception + ( + "Tried to update the last activity date for " + + "an installation, but an invalid installation id was " + + "provided." + ); + } + var installation = await _getInstallationQuery.GetByIdAsync(installationId); + if (installation == null) + { + throw new Exception + ( + "Tried to update the last activity date for " + + $"installation {installationId.ToString()}, but no " + + "installation was found for that id." + ); + } + installation.LastActivityDate = _timeProvider.GetUtcNow().UtcDateTime; + await _installationRepository.UpsertAsync(installation); + } +} diff --git a/src/Core/Platform/Installations/Entities/Installation.cs b/src/Core/Platform/Installations/Entities/Installation.cs index 63aa5d1e2458..acd53db0fb01 100644 --- a/src/Core/Platform/Installations/Entities/Installation.cs +++ b/src/Core/Platform/Installations/Entities/Installation.cs @@ -19,6 +19,7 @@ public class Installation : ITableObject public string Key { get; set; } = null!; public bool Enabled { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime? LastActivityDate { get; internal set; } public void SetNewId() { diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs new file mode 100644 index 000000000000..b0d87458006d --- /dev/null +++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs @@ -0,0 +1,30 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Queries responsible for fetching an installation from +/// `InstallationRepository`. +/// +/// +/// If referencing: you probably want the interface `IGetInstallationQuery` +/// instead of directly calling this class. +/// +/// +public class GetInstallationQuery : IGetInstallationQuery +{ + private readonly IInstallationRepository _installationRepository; + + public GetInstallationQuery(IInstallationRepository installationRepository) + { + _installationRepository = installationRepository; + } + + /// + public async Task GetByIdAsync(Guid installationId) + { + if (installationId == default(Guid)) + { + return null; + } + return await _installationRepository.GetByIdAsync(installationId); + } +} diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs new file mode 100644 index 000000000000..9615cf986d27 --- /dev/null +++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs @@ -0,0 +1,20 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Query interface responsible for fetching an installation from +/// `InstallationRepository`. +/// +/// +/// This interface is implemented by `GetInstallationQuery` +/// +/// +public interface IGetInstallationQuery +{ + /// + /// Retrieves an installation from the `InstallationRepository` by its id. + /// + /// The GUID id of the installation. + /// A task containing an `Installation`. + /// + Task GetByIdAsync(Guid installationId); +} diff --git a/src/Core/Platform/PlatformServiceCollectionExtensions.cs b/src/Core/Platform/PlatformServiceCollectionExtensions.cs new file mode 100644 index 000000000000..bba0b0aeddec --- /dev/null +++ b/src/Core/Platform/PlatformServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Bit.Core.Platform.Installations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Platform; + +public static class PlatformServiceCollectionExtensions +{ + /// + /// Extend DI to include commands and queries exported from the Platform + /// domain. + /// + public static IServiceCollection AddPlatformServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs index a42a831266fa..79b033e87717 100644 --- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs @@ -9,7 +9,6 @@ namespace Bit.Core.Platform.Push.Internal; public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService { - public RelayPushRegistrationService( IHttpClientFactory httpFactory, GlobalSettings globalSettings, diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index fb7b129b09af..597d5257e2ce 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -1,11 +1,13 @@ using System.Diagnostics; using System.Security.Claims; +using Bit.Core; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.IdentityServer; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -23,6 +25,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator _userManager; + private readonly IUpdateInstallationCommand _updateInstallationCommand; public CustomTokenRequestValidator( UserManager userManager, @@ -39,7 +42,8 @@ public CustomTokenRequestValidator( IPolicyService policyService, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, + IUpdateInstallationCommand updateInstallationCommand ) : base( userManager, @@ -59,6 +63,7 @@ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder userDecryptionOptionsBuilder) { _userManager = userManager; + _updateInstallationCommand = updateInstallationCommand; } public async Task ValidateAsync(CustomTokenRequestValidationContext context) @@ -76,16 +81,24 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context) } string[] allowedGrantTypes = ["authorization_code", "client_credentials"]; + string clientId = context.Result.ValidatedRequest.ClientId; if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType) - || context.Result.ValidatedRequest.ClientId.StartsWith("organization") - || context.Result.ValidatedRequest.ClientId.StartsWith("installation") - || context.Result.ValidatedRequest.ClientId.StartsWith("internal") + || clientId.StartsWith("organization") + || clientId.StartsWith("installation") + || clientId.StartsWith("internal") || context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets)) { if (context.Result.ValidatedRequest.Client.Properties.TryGetValue("encryptedPayload", out var payload) && !string.IsNullOrWhiteSpace(payload)) { context.Result.CustomResponse = new Dictionary { { "encrypted_payload", payload } }; + + } + if (FeatureService.IsEnabled(FeatureFlagKeys.RecordInstallationLastActivityDate) + && context.Result.ValidatedRequest.ClientId.StartsWith("installation")) + { + var installationIdPart = clientId.Split(".")[1]; + await RecordActivityForInstallation(clientId.Split(".")[1]); } return; } @@ -152,6 +165,7 @@ protected override Task SetSuccessResult(CustomTokenRequestValidationContext con context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl; context.Result.CustomResponse["ResetMasterPassword"] = false; } + return Task.CompletedTask; } @@ -202,4 +216,25 @@ protected override void SetValidationErrorResult( context.Result.ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription; context.Result.CustomResponse = requestContext.CustomResponse; } + + /// + /// To help mentally separate organizations that self host from abandoned + /// organizations we hook in to the token refresh event for installations + /// to write a simple `DateTime.Now` to the database. + /// + /// + /// This works well because installations don't phone home very often. + /// Currently self hosted installations only refresh tokens every 24 + /// hours or so for the sake of hooking in to cloud's push relay service. + /// If installations ever start refreshing tokens more frequently we may need to + /// adjust this to avoid making a bunch of unnecessary database calls! + /// + private async Task RecordActivityForInstallation(string? installationIdString) + { + if (!Guid.TryParse(installationIdString, out var installationId)) + { + return; + } + await _updateInstallationCommand.UpdateLastActivityDateAsync(installationId); + } } diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 499c22ad8961..085ed15efd90 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -44,8 +44,7 @@ public WebAuthnGrantValidator( IDataProtectorTokenFactory assertionOptionsDataProtector, IFeatureService featureService, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, - IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand - ) + IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand) : base( userManager, userService, diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 46f8293d3eab..891b8d6664bc 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -30,6 +30,7 @@ using Bit.Core.NotificationCenter; using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; +using Bit.Core.Platform; using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; @@ -126,6 +127,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddReportingServices(); services.AddKeyManagementServices(); services.AddNotificationCenterServices(); + services.AddPlatformServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs b/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs new file mode 100644 index 000000000000..daa8e1b89cba --- /dev/null +++ b/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs @@ -0,0 +1,40 @@ +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Platform.Installations.Tests; + +[SutProviderCustomize] +public class UpdateInstallationCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateLastActivityDateAsync_ShouldUpdateLastActivityDate( + Installation installation + ) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + var someDate = new DateTime(2014, 11, 3, 18, 27, 0, DateTimeKind.Utc); + sutProvider.GetDependency().SetUtcNow(someDate); + + sutProvider + .GetDependency() + .GetByIdAsync(installation.Id) + .Returns(installation); + + // Act + await sutProvider.Sut.UpdateLastActivityDateAsync(installation.Id); + + // Assert + await sutProvider + .GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(inst => inst.LastActivityDate == someDate)); + } +}