From 20bf1455cf2b67ba0d3e1fe4846a3d71856a4eaf Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 30 Jun 2025 13:17:51 -0400 Subject: [PATCH] [PM-20348] Add pending auth request endpoint (#5957) * Feat(pm-20348): * Add migration scripts for Read Pending Auth Requests by UserId stored procedure and new `view` for pending AuthRequest. * View only returns the most recent pending authRequest, or none at all if the most recent is answered. * Implement stored procedure in AuthRequestRepository for both Dapper and Entity Framework. * Update AuthRequestController to query the new View to get a user's most recent pending auth requests response includes the requesting deviceId. * Doc: * Move summary xml comments to interface. * Added comments for the AuthRequestService. * Test: * Added testing for AuthRequestsController. * Added testing for repositories. * Added integration tests for multiple auth requests but only returning the most recent. --- .../Controllers/AuthRequestsController.cs | 40 +-- .../PendingAuthRequestResponseModel.cs | 15 + .../Models/Data/PendingAuthRequestDetails.cs | 83 ++++++ .../Repositories/IAuthRequestRepository.cs | 7 + src/Core/Auth/Services/IAuthRequestService.cs | 41 ++- .../Implementations/AuthRequestService.cs | 16 +- .../Repositories/AuthRequestRepository.cs | 23 +- .../Repositories/AuthRequestRepository.cs | 37 ++- .../AuthRequest_ReadPendingByUserId.sql | 12 + .../Views/AuthRequestPendingDetailsView.sql | 38 +++ test/Api.Test/Api.Test.csproj | 5 - .../AuthRequestsControllerTests.cs | 258 ++++++++++++++++++ .../AuthRequestRepositoryTests.cs | 174 +++++++++++- ...-00_AddReadPendingAuthRequestsByUserId.sql | 53 ++++ 14 files changed, 752 insertions(+), 50 deletions(-) create mode 100644 src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs create mode 100644 src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs create mode 100644 src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql create mode 100644 src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql create mode 100644 test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs create mode 100644 util/Migrator/DbScripts/2025-06-04-00_AddReadPendingAuthRequestsByUserId.sql diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index f7edc7dec4..d1d6a8a524 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -1,5 +1,6 @@ using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; @@ -7,6 +8,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,31 +16,23 @@ namespace Bit.Api.Auth.Controllers; [Route("auth-requests")] [Authorize("Application")] -public class AuthRequestsController : Controller +public class AuthRequestsController( + IUserService userService, + IAuthRequestRepository authRequestRepository, + IGlobalSettings globalSettings, + IAuthRequestService authRequestService) : Controller { - private readonly IUserService _userService; - private readonly IAuthRequestRepository _authRequestRepository; - private readonly IGlobalSettings _globalSettings; - private readonly IAuthRequestService _authRequestService; - - public AuthRequestsController( - IUserService userService, - IAuthRequestRepository authRequestRepository, - IGlobalSettings globalSettings, - IAuthRequestService authRequestService) - { - _userService = userService; - _authRequestRepository = authRequestRepository; - _globalSettings = globalSettings; - _authRequestService = authRequestService; - } + private readonly IUserService _userService = userService; + private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository; + private readonly IGlobalSettings _globalSettings = globalSettings; + private readonly IAuthRequestService _authRequestService = authRequestService; [HttpGet("")] public async Task> Get() { var userId = _userService.GetProperUserId(User).Value; var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId); - var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)).ToList(); + var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)); return new ListResponseModel(responses); } @@ -56,6 +50,16 @@ public class AuthRequestsController : Controller return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } + [HttpGet("pending")] + [RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)] + public async Task> GetPendingAuthRequestsAsync() + { + var userId = _userService.GetProperUserId(User).Value; + var rawResponse = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId); + var responses = rawResponse.Select(a => new PendingAuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)); + return new ListResponseModel(responses); + } + [HttpGet("{id}/response")] [AllowAnonymous] public async Task GetResponse(Guid id, [FromQuery] string code) diff --git a/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs b/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs new file mode 100644 index 0000000000..8428593068 --- /dev/null +++ b/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs @@ -0,0 +1,15 @@ +using Bit.Core.Auth.Models.Data; + +namespace Bit.Api.Auth.Models.Response; + +public class PendingAuthRequestResponseModel : AuthRequestResponseModel +{ + public PendingAuthRequestResponseModel(PendingAuthRequestDetails authRequest, string vaultUri, string obj = "auth-request") + : base(authRequest, vaultUri, obj) + { + ArgumentNullException.ThrowIfNull(authRequest); + RequestDeviceId = authRequest.RequestDeviceId; + } + + public Guid? RequestDeviceId { get; set; } +} diff --git a/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs b/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs new file mode 100644 index 0000000000..0755e941b7 --- /dev/null +++ b/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs @@ -0,0 +1,83 @@ + +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Enums; + +namespace Bit.Core.Auth.Models.Data; + +public class PendingAuthRequestDetails : AuthRequest +{ + public Guid? RequestDeviceId { get; set; } + + /** + * Constructor for EF response. + */ + public PendingAuthRequestDetails( + AuthRequest authRequest, + Guid? deviceId) + { + ArgumentNullException.ThrowIfNull(authRequest); + + Id = authRequest.Id; + UserId = authRequest.UserId; + OrganizationId = authRequest.OrganizationId; + Type = authRequest.Type; + RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier; + RequestDeviceType = authRequest.RequestDeviceType; + RequestIpAddress = authRequest.RequestIpAddress; + RequestCountryName = authRequest.RequestCountryName; + ResponseDeviceId = authRequest.ResponseDeviceId; + AccessCode = authRequest.AccessCode; + PublicKey = authRequest.PublicKey; + Key = authRequest.Key; + MasterPasswordHash = authRequest.MasterPasswordHash; + Approved = authRequest.Approved; + CreationDate = authRequest.CreationDate; + ResponseDate = authRequest.ResponseDate; + AuthenticationDate = authRequest.AuthenticationDate; + RequestDeviceId = deviceId; + } + + /** + * Constructor for dapper response. + */ + public PendingAuthRequestDetails( + Guid id, + Guid userId, + Guid organizationId, + short type, + string requestDeviceIdentifier, + short requestDeviceType, + string requestIpAddress, + string requestCountryName, + Guid? responseDeviceId, + string accessCode, + string publicKey, + string key, + string masterPasswordHash, + bool? approved, + DateTime creationDate, + DateTime? responseDate, + DateTime? authenticationDate, + Guid deviceId) + { + Id = id; + UserId = userId; + OrganizationId = organizationId; + Type = (AuthRequestType)type; + RequestDeviceIdentifier = requestDeviceIdentifier; + RequestDeviceType = (DeviceType)requestDeviceType; + RequestIpAddress = requestIpAddress; + RequestCountryName = requestCountryName; + ResponseDeviceId = responseDeviceId; + AccessCode = accessCode; + PublicKey = publicKey; + Key = key; + MasterPasswordHash = masterPasswordHash; + Approved = approved; + CreationDate = creationDate; + ResponseDate = responseDate; + AuthenticationDate = authenticationDate; + RequestDeviceId = deviceId; + } +} diff --git a/src/Core/Auth/Repositories/IAuthRequestRepository.cs b/src/Core/Auth/Repositories/IAuthRequestRepository.cs index 3b01a452f9..7a66ad6e34 100644 --- a/src/Core/Auth/Repositories/IAuthRequestRepository.cs +++ b/src/Core/Auth/Repositories/IAuthRequestRepository.cs @@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository { Task DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration); Task> GetManyByUserIdAsync(Guid userId); + /// + /// Gets all active pending auth requests for a user. Each auth request in the collection will be associated with a different + /// device. It will be the most current request for the device. + /// + /// UserId of the owner of the AuthRequests + /// a collection Auth request details or empty + Task> GetManyPendingAuthRequestByUserId(Guid userId); Task> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable ids); Task UpdateManyAsync(IEnumerable authRequests); diff --git a/src/Core/Auth/Services/IAuthRequestService.cs b/src/Core/Auth/Services/IAuthRequestService.cs index 4e057f0ccf..d81f6e7c8c 100644 --- a/src/Core/Auth/Services/IAuthRequestService.cs +++ b/src/Core/Auth/Services/IAuthRequestService.cs @@ -1,5 +1,9 @@ using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Exceptions; using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Settings; #nullable enable @@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services; public interface IAuthRequestService { - Task GetAuthRequestAsync(Guid id, Guid userId); - Task GetValidatedAuthRequestAsync(Guid id, string code); + /// + /// Fetches an authRequest by Id. Returns AuthRequest if AuthRequest.UserId mateches + /// userId. Returns null if the user doesn't match or if the AuthRequest is not found. + /// + /// Authrequest Id being fetched + /// user who owns AuthRequest + /// An AuthRequest or null + Task GetAuthRequestAsync(Guid authRequestId, Guid userId); + /// + /// Fetches the authrequest from the database with the id provided. Then checks + /// the accessCode against the AuthRequest.AccessCode from the database. accessCodes + /// must match the found authRequest, and the AuthRequest must not be expired. Expiration + /// is configured in + /// + /// AuthRequest being acted on + /// Access code of the authrequest, must match saved database value + /// A valid AuthRequest or null + Task GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode); + /// + /// Validates and Creates an in the database, as well as pushes it through notifications services + /// + /// + /// This method can only be called inside of an HTTP call because of it's reliance on + /// Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model); + /// + /// Updates the AuthRequest per the AuthRequestUpdateRequestModel context. This approves + /// or rejects the login request. + /// + /// AuthRequest being acted on. + /// User acting on AuthRequest + /// Update context for the AuthRequest + /// retuns an AuthRequest or throws an exception + /// Thows if the AuthRequest has already been Approved/Rejected + /// Throws if the AuthRequest as expired or the userId doesn't match + /// Throws if the device isn't associated with the UserId Task UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model); } diff --git a/src/Core/Auth/Services/Implementations/AuthRequestService.cs b/src/Core/Auth/Services/Implementations/AuthRequestService.cs index 0fd1846d00..11682b524f 100644 --- a/src/Core/Auth/Services/Implementations/AuthRequestService.cs +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService _logger = logger; } - public async Task GetAuthRequestAsync(Guid id, Guid userId) + public async Task GetAuthRequestAsync(Guid authRequestId, Guid userId) { - var authRequest = await _authRequestRepository.GetByIdAsync(id); + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); if (authRequest == null || authRequest.UserId != userId) { return null; @@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService return authRequest; } - public async Task GetValidatedAuthRequestAsync(Guid id, string code) + public async Task GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode) { - var authRequest = await _authRequestRepository.GetByIdAsync(id); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code)) + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); + if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode)) { return null; } @@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService return authRequest; } - /// - /// Validates and Creates an in the database, as well as pushes it through notifications services - /// - /// - /// This method can only be called inside of an HTTP call because of it's reliance on - /// public async Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model) { if (!_currentContext.DeviceType.HasValue) diff --git a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs index db6419d389..c9cf796986 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs @@ -14,13 +14,12 @@ namespace Bit.Infrastructure.Dapper.Auth.Repositories; public class AuthRequestRepository : Repository, IAuthRequestRepository { + private readonly GlobalSettings _globalSettings; public AuthRequestRepository(GlobalSettings globalSettings) - : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) - { } - - public AuthRequestRepository(string connectionString, string readOnlyConnectionString) - : base(connectionString, readOnlyConnectionString) - { } + : base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { + _globalSettings = globalSettings; + } public async Task DeleteExpiredAsync( TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration) @@ -52,6 +51,18 @@ public class AuthRequestRepository : Repository, IAuthRequest } } + public async Task> GetManyPendingAuthRequestByUserId(Guid userId) + { + var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes; + using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AuthRequest_ReadPendingByUserId]", + new { UserId = userId, ExpirationMinutes = expirationMinutes }, + commandType: CommandType.StoredProcedure); + + return results; + } + public async Task> GetManyPendingByOrganizationIdAsync(Guid organizationId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs index 7dee40a9e6..23cdb60dcd 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs @@ -3,6 +3,7 @@ using AutoMapper.QueryableExtensions; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Settings; using Bit.Infrastructure.EntityFramework.Auth.Models; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; @@ -14,9 +15,13 @@ namespace Bit.Infrastructure.EntityFramework.Auth.Repositories; public class AuthRequestRepository : Repository, IAuthRequestRepository { - public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) - : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.AuthRequests) - { } + private readonly IGlobalSettings _globalSettings; + public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper, IGlobalSettings globalSettings) + : base(serviceScopeFactory, mapper, context => context.AuthRequests) + { + _globalSettings = globalSettings; + } + public async Task DeleteExpiredAsync( TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration) { @@ -57,6 +62,32 @@ public class AuthRequestRepository : Repository> GetManyPendingAuthRequestByUserId(Guid userId) + { + var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes; + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var mostRecentAuthRequests = await + (from authRequest in dbContext.AuthRequests + where authRequest.Type == AuthRequestType.AuthenticateAndUnlock + || authRequest.Type == AuthRequestType.Unlock + where authRequest.UserId == userId + where authRequest.CreationDate.AddMinutes(expirationMinutes) >= DateTime.UtcNow + group authRequest by authRequest.RequestDeviceIdentifier into groupedAuthRequests + select + (from r in groupedAuthRequests + join d in dbContext.Devices on new { r.RequestDeviceIdentifier, r.UserId } + equals new { RequestDeviceIdentifier = d.Identifier, d.UserId } into deviceJoin + from dj in deviceJoin.DefaultIfEmpty() // This creates a left join allowing null for devices + orderby r.CreationDate descending + select new PendingAuthRequestDetails(r, dj.Id)).First() + ).ToListAsync(); + + mostRecentAuthRequests.RemoveAll(a => a.Approved != null); + + return mostRecentAuthRequests; + } + public async Task> GetManyAdminApprovalRequestsByManyIdsAsync( Guid organizationId, IEnumerable ids) diff --git a/src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql b/src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql new file mode 100644 index 0000000000..4c3217812a --- /dev/null +++ b/src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[AuthRequest_ReadPendingByUserId] + @UserId UNIQUEIDENTIFIER, + @ExpirationMinutes INT +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AuthRequestPendingDetailsView] + WHERE [UserId] = @UserId + AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) +END diff --git a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql new file mode 100644 index 0000000000..d0433bca09 --- /dev/null +++ b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql @@ -0,0 +1,38 @@ +CREATE VIEW [dbo].[AuthRequestPendingDetailsView] +AS + WITH + PendingRequests + AS + ( + SELECT + [AR].*, + [D].[Id] AS [DeviceId], + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn] + FROM [dbo].[AuthRequest] [AR] + LEFT JOIN [dbo].[Device] [D] + ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] + AND [D].[UserId] = [AR].[UserId] + WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock + ) + SELECT + [PR].[Id], + [PR].[UserId], + [PR].[OrganizationId], + [PR].[Type], + [PR].[RequestDeviceIdentifier], + [PR].[RequestDeviceType], + [PR].[RequestIpAddress], + [PR].[RequestCountryName], + [PR].[ResponseDeviceId], + [PR].[AccessCode], + [PR].[PublicKey], + [PR].[Key], + [PR].[MasterPasswordHash], + [PR].[Approved], + [PR].[CreationDate], + [PR].[ResponseDate], + [PR].[AuthenticationDate], + [PR].[DeviceId] + FROM [PendingRequests] [PR] + WHERE [PR].[rn] = 1 + AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index d6b31ce930..fb75246d4f 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -25,9 +25,4 @@ - - - - - diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs new file mode 100644 index 0000000000..828911f6bd --- /dev/null +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -0,0 +1,258 @@ +using System.Security.Claims; +using Bit.Api.Auth.Controllers; +using Bit.Api.Auth.Models.Response; +using Bit.Api.Models.Response; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Services; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Controllers; + +[ControllerCustomize(typeof(AuthRequestsController))] +[SutProviderCustomize] +public class AuthRequestsControllerTests +{ + const string _testGlobalSettingsBaseUri = "https://vault.test.dev"; + + [Theory, BitAutoData] + public async Task Get_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns([authRequest]); + + // Act + var result = await sutProvider.Sut.Get(); + + // Assert + Assert.NotNull(result); + var expectedCount = 1; + Assert.Equal(result.Data.Count(), expectedCount); + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task GetById_ThrowsNotFoundException( + SutProvider sutProvider, + User user, + AuthRequest authRequest) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequest.Id, user.Id) + .Returns((AuthRequest)null); + + // Act + // Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Get(authRequest.Id)); + } + + [Theory, BitAutoData] + public async Task GetById_ReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequest.Id, user.Id) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.Get(authRequest.Id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetPending_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + PendingAuthRequestDetails authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns([authRequest]); + + // Act + var result = await sutProvider.Sut.GetPendingAuthRequestsAsync(); + + // Assert + Assert.NotNull(result); + var expectedCount = 1; + Assert.Equal(result.Data.Count(), expectedCount); + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task GetResponseById_ThrowsNotFoundException( + SutProvider sutProvider, + AuthRequest authRequest) + { + // Arrange + sutProvider.GetDependency() + .GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode) + .Returns((AuthRequest)null); + + // Act + // Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode)); + } + + [Theory, BitAutoData] + public async Task GetResponseById_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Post_AdminApprovalRequest_ThrowsBadRequestException( + SutProvider sutProvider, + AuthRequestCreateRequestModel authRequest) + { + // Arrange + authRequest.Type = AuthRequestType.AdminApproval; + + // Act + // Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Post(authRequest)); + + var expectedMessage = "You must be authenticated to create a request of that type."; + Assert.Equal(exception.Message, expectedMessage); + } + + [Theory, BitAutoData] + public async Task Post_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel requestModel, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + requestModel.Type = AuthRequestType.AuthenticateAndUnlock; + sutProvider.GetDependency() + .CreateAuthRequestAsync(requestModel) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.Post(requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task PostAdminRequest_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel requestModel, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + requestModel.Type = AuthRequestType.AuthenticateAndUnlock; + sutProvider.GetDependency() + .CreateAuthRequestAsync(requestModel) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.PostAdminRequest(requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_ReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .UpdateAuthRequestAsync(authRequest.Id, user.Id, requestModel) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut + .Put(authRequest.Id, requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + private void SetBaseServiceUri(SutProvider sutProvider) + { + sutProvider.GetDependency() + .BaseServiceUri + .Vault + .Returns(_testGlobalSettingsBaseUri); + } +} diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs index 8cd8cb607c..56f01748fb 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs @@ -66,10 +66,8 @@ public class AuthRequestRepositoryTests Assert.NotNull(await authRequestRepository.GetByIdAsync(notExpiredAdminApprovalRequest.Id)); Assert.NotNull(await authRequestRepository.GetByIdAsync(notExpiredApprovedAdminApprovalRequest.Id)); - // Ensure the repository responds with the amount of items it deleted and it deleted the right amount. - // NOTE: On local development this might fail on it's first run because the developer could have expired AuthRequests - // on their machine but aren't running the job that would delete them. The second run of this test should succeed. - Assert.Equal(4, numberOfDeleted); + // Ensure the repository responds with the amount of items it deleted and it deleted the right amount, which could include other auth requests from other tests so we take the minimum known acceptable amount. + Assert.True(numberOfDeleted >= 4); } [DatabaseTheory, DatabaseData] @@ -182,7 +180,157 @@ public class AuthRequestRepositoryTests Assert.Null(uncreatedAuthRequest); } - private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null) + /// + /// Test to determine that when no valid authRequest exists in the database the return value is null. + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyPendingAuthRequestByUserId_AuthRequestsInvalid_ReturnsEmptyEnumerable_Success( + IAuthRequestRepository authRequestRepository, + IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + List authRequests = []; + + // A user auth request type that has passed its expiration time, should not be returned. + var authRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + CreateExpiredDate(_userRequestExpiration)); + authRequest.RequestDeviceIdentifier = "auth_request_expired"; + authRequests.Add(await authRequestRepository.CreateAsync(authRequest)); + + // A valid time AuthRequest but for pending we do not fetch admin auth requests + authRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AdminApproval, + DateTime.UtcNow.AddMinutes(-1)); + authRequest.RequestDeviceIdentifier = "admin_auth_request"; + authRequests.Add(await authRequestRepository.CreateAsync(authRequest)); + + // A valid time AuthRequest but the request has been approved/rejected, so it should not be returned. + authRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-1), + false); + authRequest.RequestDeviceIdentifier = "approved_auth_request"; + authRequests.Add(await authRequestRepository.CreateAsync(authRequest)); + + var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id); + Assert.NotNull(result); + Assert.Empty(result); + + // Verify that there are authRequests associated with the user. + Assert.NotEmpty(await authRequestRepository.GetManyByUserIdAsync(user.Id)); + + await CleanupTestAsync(authRequests, authRequestRepository); + } + + /// + /// Test to determine that when multiple valid authRequest exist for a device only the soonest one is returned. + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyPendingAuthRequestByUserId_MultipleRequestForSingleDevice_ReturnsMostRecent( + IAuthRequestRepository authRequestRepository, + IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var oneMinuteOldAuthRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-1)); + oneMinuteOldAuthRequest = await authRequestRepository.CreateAsync(oneMinuteOldAuthRequest); + + var fiveMinuteOldAuthRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-5)); + fiveMinuteOldAuthRequest = await authRequestRepository.CreateAsync(fiveMinuteOldAuthRequest); + + var tenMinuteOldAuthRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-10)); + tenMinuteOldAuthRequest = await authRequestRepository.CreateAsync(tenMinuteOldAuthRequest); + + var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id); + Assert.NotNull(result); + // since we group by device there should only be a single return since the device Id is the same + Assert.Single(result); + var resultAuthRequest = result.First(); + Assert.Equal(oneMinuteOldAuthRequest.Id, resultAuthRequest.Id); + + List authRequests = [oneMinuteOldAuthRequest, fiveMinuteOldAuthRequest, tenMinuteOldAuthRequest]; + + await CleanupTestAsync(authRequests, authRequestRepository); + } + + /// + /// Test to determine that when multiple authRequests exist for a device if the most recent is approved then + /// there should be no return. + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyPendingAuthRequestByUserId_MultipleRequestForSingleDevice_MostRecentIsApproved_ReturnsEmpty( + IAuthRequestRepository authRequestRepository, + IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // approved auth request + var oneMinuteOldAuthRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-1), + false); + oneMinuteOldAuthRequest = await authRequestRepository.CreateAsync(oneMinuteOldAuthRequest); + + var fiveMinuteOldAuthRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-5)); + fiveMinuteOldAuthRequest = await authRequestRepository.CreateAsync(fiveMinuteOldAuthRequest); + + var tenMinuteOldAuthRequest = CreateAuthRequest( + user.Id, + AuthRequestType.AuthenticateAndUnlock, + DateTime.UtcNow.AddMinutes(-10)); + tenMinuteOldAuthRequest = await authRequestRepository.CreateAsync(tenMinuteOldAuthRequest); + + var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id); + Assert.NotNull(result); + // result should be empty since the most recent request was addressed + Assert.Empty(result); + + List authRequests = [oneMinuteOldAuthRequest, fiveMinuteOldAuthRequest, tenMinuteOldAuthRequest]; + await CleanupTestAsync(authRequests, authRequestRepository); + } + + private static AuthRequest CreateAuthRequest( + Guid userId, + AuthRequestType authRequestType, + DateTime creationDate, + bool? approved = null, + DateTime? responseDate = null) { return new AuthRequest { @@ -203,4 +351,20 @@ public class AuthRequestRepositoryTests var exp = expirationPeriod + TimeSpan.FromMinutes(1); return DateTime.UtcNow.Add(exp.Negate()); } + + /// + /// Cleans up the test data created by the test methods. This supports the DeleteExpiredAsync Test. + /// + /// Created Auth Requests + /// repository context for the current test + /// void + private static async Task CleanupTestAsync( + IEnumerable authRequests, + IAuthRequestRepository authRequestRepository) + { + foreach (var authRequest in authRequests) + { + await authRequestRepository.DeleteAsync(authRequest); + } + } } diff --git a/util/Migrator/DbScripts/2025-06-04-00_AddReadPendingAuthRequestsByUserId.sql b/util/Migrator/DbScripts/2025-06-04-00_AddReadPendingAuthRequestsByUserId.sql new file mode 100644 index 0000000000..86f4683cff --- /dev/null +++ b/util/Migrator/DbScripts/2025-06-04-00_AddReadPendingAuthRequestsByUserId.sql @@ -0,0 +1,53 @@ +CREATE OR ALTER VIEW [dbo].[AuthRequestPendingDetailsView] +AS + WITH + PendingRequests + AS + ( + SELECT + [AR].*, + [D].[Id] AS [DeviceId], + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn] + FROM [dbo].[AuthRequest] [AR] + LEFT JOIN [dbo].[Device] [D] + ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] + AND [D].[UserId] = [AR].[UserId] + WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock + ) + SELECT + [PR].[Id], + [PR].[UserId], + [PR].[OrganizationId], + [PR].[Type], + [PR].[RequestDeviceIdentifier], + [PR].[RequestDeviceType], + [PR].[RequestIpAddress], + [PR].[RequestCountryName], + [PR].[ResponseDeviceId], + [PR].[AccessCode], + [PR].[PublicKey], + [PR].[Key], + [PR].[MasterPasswordHash], + [PR].[Approved], + [PR].[CreationDate], + [PR].[ResponseDate], + [PR].[AuthenticationDate], + [PR].[DeviceId] + FROM [PendingRequests] [PR] + WHERE [PR].[rn] = 1 + AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null + GO + +CREATE OR ALTER PROCEDURE [dbo].[AuthRequest_ReadPendingByUserId] + @UserId UNIQUEIDENTIFIER, + @ExpirationMinutes INT +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AuthRequestPendingDetailsView] + WHERE [UserId] = @UserId + AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) +END + GO