diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index 98f92cb68..8595ff4a4 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -1,14 +1,11 @@ -using Bit.Api.Auth.Models.Request; -using Bit.Api.Auth.Models.Response; +using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; -using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Exceptions; -using Bit.Core.Context; +using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Auth.Services; 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; @@ -18,30 +15,21 @@ namespace Bit.Api.Auth.Controllers; [Authorize("Application")] public class AuthRequestsController : Controller { - private readonly IUserRepository _userRepository; - private readonly IDeviceRepository _deviceRepository; private readonly IUserService _userService; private readonly IAuthRequestRepository _authRequestRepository; - private readonly ICurrentContext _currentContext; - private readonly IPushNotificationService _pushNotificationService; private readonly IGlobalSettings _globalSettings; + private readonly IAuthRequestService _authRequestService; public AuthRequestsController( - IUserRepository userRepository, - IDeviceRepository deviceRepository, IUserService userService, IAuthRequestRepository authRequestRepository, - ICurrentContext currentContext, - IPushNotificationService pushNotificationService, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + IAuthRequestService authRequestService) { - _userRepository = userRepository; - _deviceRepository = deviceRepository; _userService = userService; _authRequestRepository = authRequestRepository; - _currentContext = currentContext; - _pushNotificationService = pushNotificationService; _globalSettings = globalSettings; + _authRequestService = authRequestService; } [HttpGet("")] @@ -54,11 +42,12 @@ public class AuthRequestsController : Controller } [HttpGet("{id}")] - public async Task Get(string id) + public async Task Get(Guid id) { var userId = _userService.GetProperUserId(User).Value; - var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(id)); - if (authRequest == null || authRequest.UserId != userId) + var authRequest = await _authRequestService.GetAuthRequestAsync(id, userId); + + if (authRequest == null) { throw new NotFoundException(); } @@ -68,10 +57,11 @@ public class AuthRequestsController : Controller [HttpGet("{id}/response")] [AllowAnonymous] - public async Task GetResponse(string id, [FromQuery] string code) + public async Task GetResponse(Guid id, [FromQuery] string code) { - var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(id)); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code) || authRequest.GetExpirationDate() < DateTime.UtcNow) + var authRequest = await _authRequestService.GetValidatedAuthRequestAsync(id, code); + + if (authRequest == null) { throw new NotFoundException(); } @@ -83,80 +73,16 @@ public class AuthRequestsController : Controller [AllowAnonymous] public async Task Post([FromBody] AuthRequestCreateRequestModel model) { - var user = await _userRepository.GetByEmailAsync(model.Email); - if (user == null) - { - throw new NotFoundException(); - } - if (!_currentContext.DeviceType.HasValue) - { - throw new BadRequestException("Device type not provided."); - } - if (_globalSettings.PasswordlessAuth.KnownDevicesOnly) - { - var devices = await _deviceRepository.GetManyByUserIdAsync(user.Id); - if (devices == null || !devices.Any(d => d.Identifier == model.DeviceIdentifier)) - { - throw new BadRequestException("Login with device is only available on devices that have been previously logged in."); - } - } - - var authRequest = new AuthRequest - { - RequestDeviceIdentifier = model.DeviceIdentifier, - RequestDeviceType = _currentContext.DeviceType.Value, - RequestIpAddress = _currentContext.IpAddress, - AccessCode = model.AccessCode, - PublicKey = model.PublicKey, - UserId = user.Id, - Type = model.Type.Value - }; - authRequest = await _authRequestRepository.CreateAsync(authRequest); - await _pushNotificationService.PushAuthRequestAsync(authRequest); + var authRequest = await _authRequestService.CreateAuthRequestAsync(model); var r = new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); return r; } [HttpPut("{id}")] - public async Task Put(string id, [FromBody] AuthRequestUpdateRequestModel model) + public async Task Put(Guid id, [FromBody] AuthRequestUpdateRequestModel model) { var userId = _userService.GetProperUserId(User).Value; - var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(id)); - if (authRequest == null || authRequest.UserId != userId || authRequest.GetExpirationDate() < DateTime.UtcNow) - { - throw new NotFoundException(); - } - - if (authRequest.Approved is not null) - { - throw new DuplicateAuthRequestException(); - } - - var device = await _deviceRepository.GetByIdentifierAsync(model.DeviceIdentifier, userId); - if (device == null) - { - throw new BadRequestException("Invalid device."); - } - - authRequest.ResponseDeviceId = device.Id; - authRequest.ResponseDate = DateTime.UtcNow; - authRequest.Approved = model.RequestApproved; - - if (model.RequestApproved) - { - authRequest.Key = model.Key; - authRequest.MasterPasswordHash = model.MasterPasswordHash; - } - - await _authRequestRepository.ReplaceAsync(authRequest); - - // We only want to send an approval notification if the request is approved (or null), - // to not leak that it was denied to the originating client if it was originated by a malicious actor. - if (authRequest.Approved ?? true) - { - await _pushNotificationService.PushAuthRequestResponseAsync(authRequest); - } - + var authRequest = await _authRequestService.UpdateAuthRequestAsync(id, userId, model); return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } } diff --git a/src/Api/Auth/Models/Request/AuthRequestRequestModel.cs b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs similarity index 57% rename from src/Api/Auth/Models/Request/AuthRequestRequestModel.cs rename to src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs index 3403fd276..e7cd05be2 100644 --- a/src/Api/Auth/Models/Request/AuthRequestRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; -using Newtonsoft.Json; -namespace Bit.Api.Auth.Models.Request; +namespace Bit.Core.Auth.Models.Api.Request.AuthRequest; public class AuthRequestCreateRequestModel { @@ -18,13 +17,3 @@ public class AuthRequestCreateRequestModel [Required] public AuthRequestType? Type { get; set; } } - -public class AuthRequestUpdateRequestModel -{ - public string Key { get; set; } - public string MasterPasswordHash { get; set; } - [Required] - public string DeviceIdentifier { get; set; } - [Required] - public bool RequestApproved { get; set; } -} diff --git a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs new file mode 100644 index 000000000..1577f3a1c --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Auth.Models.Api.Request.AuthRequest; + +public class AuthRequestUpdateRequestModel +{ + public string Key { get; set; } + public string MasterPasswordHash { get; set; } + [Required] + public string DeviceIdentifier { get; set; } + [Required] + public bool RequestApproved { get; set; } +} diff --git a/src/Core/Auth/Services/IAuthRequestService.cs b/src/Core/Auth/Services/IAuthRequestService.cs new file mode 100644 index 000000000..4e057f0cc --- /dev/null +++ b/src/Core/Auth/Services/IAuthRequestService.cs @@ -0,0 +1,14 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Api.Request.AuthRequest; + +#nullable enable + +namespace Bit.Core.Auth.Services; + +public interface IAuthRequestService +{ + Task GetAuthRequestAsync(Guid id, Guid userId); + Task GetValidatedAuthRequestAsync(Guid id, string code); + Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model); + 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 new file mode 100644 index 000000000..b75b4decf --- /dev/null +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -0,0 +1,149 @@ +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.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +#nullable enable + +namespace Bit.Core.Auth.Services.Implementations; + +public class AuthRequestService : IAuthRequestService +{ + private readonly IAuthRequestRepository _authRequestRepository; + private readonly IUserRepository _userRepository; + private readonly IGlobalSettings _globalSettings; + private readonly IDeviceRepository _deviceRepository; + private readonly ICurrentContext _currentContext; + private readonly IPushNotificationService _pushNotificationService; + + public AuthRequestService( + IAuthRequestRepository authRequestRepository, + IUserRepository userRepository, + IGlobalSettings globalSettings, + IDeviceRepository deviceRepository, + ICurrentContext currentContext, + IPushNotificationService pushNotificationService) + { + _authRequestRepository = authRequestRepository; + _userRepository = userRepository; + _globalSettings = globalSettings; + _deviceRepository = deviceRepository; + _currentContext = currentContext; + _pushNotificationService = pushNotificationService; + } + + public async Task GetAuthRequestAsync(Guid id, Guid userId) + { + var authRequest = await _authRequestRepository.GetByIdAsync(id); + if (authRequest == null || authRequest.UserId != userId) + { + return null; + } + + return authRequest; + } + + public async Task GetValidatedAuthRequestAsync(Guid id, string code) + { + var authRequest = await _authRequestRepository.GetByIdAsync(id); + if (authRequest == null || + !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code) || + authRequest.GetExpirationDate() < DateTime.UtcNow) + { + return null; + } + + 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) + { + var user = await _userRepository.GetByEmailAsync(model.Email); + if (user == null) + { + throw new NotFoundException(); + } + + if (!_currentContext.DeviceType.HasValue) + { + throw new BadRequestException("Device type not provided."); + } + + if (_globalSettings.PasswordlessAuth.KnownDevicesOnly) + { + var devices = await _deviceRepository.GetManyByUserIdAsync(user.Id); + if (devices == null || !devices.Any(d => d.Identifier == model.DeviceIdentifier)) + { + throw new BadRequestException( + "Login with device is only available on devices that have been previously logged in."); + } + } + + var authRequest = new AuthRequest + { + RequestDeviceIdentifier = model.DeviceIdentifier, + RequestDeviceType = _currentContext.DeviceType.Value, + RequestIpAddress = _currentContext.IpAddress, + AccessCode = model.AccessCode, + PublicKey = model.PublicKey, + UserId = user.Id, + Type = model.Type.GetValueOrDefault(), + }; + + authRequest = await _authRequestRepository.CreateAsync(authRequest); + await _pushNotificationService.PushAuthRequestAsync(authRequest); + return authRequest; + } + + public async Task UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model) + { + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); + if (authRequest == null || authRequest.UserId != userId || authRequest.GetExpirationDate() < DateTime.UtcNow) + { + throw new NotFoundException(); + } + + if (authRequest.Approved is not null) + { + throw new DuplicateAuthRequestException(); + } + + var device = await _deviceRepository.GetByIdentifierAsync(model.DeviceIdentifier, userId); + if (device == null) + { + throw new BadRequestException("Invalid device."); + } + + authRequest.ResponseDeviceId = device.Id; + authRequest.ResponseDate = DateTime.UtcNow; + authRequest.Approved = model.RequestApproved; + + if (model.RequestApproved) + { + authRequest.Key = model.Key; + authRequest.MasterPasswordHash = model.MasterPasswordHash; + } + + await _authRequestRepository.ReplaceAsync(authRequest); + + // We only want to send an approval notification if the request is approved (or null), + // to not leak that it was denied to the originating client if it was originated by a malicious actor. + if (authRequest.Approved ?? true) + { + await _pushNotificationService.PushAuthRequestResponseAsync(authRequest); + } + + return authRequest; + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index b0a0f09ad..2a1bcbf92 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.LoginFeatures; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; @@ -130,6 +131,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddLoginServices(); services.AddScoped(); diff --git a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs new file mode 100644 index 000000000..a59551166 --- /dev/null +++ b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs @@ -0,0 +1,393 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Exceptions; +using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Auth.Services.Implementations; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +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; + +#nullable enable + +namespace Bit.Core.Test.Auth.Services; + +[SutProviderCustomize] +public class AuthRequestServiceTests +{ + [Theory, BitAutoData] + public async Task GetAuthRequestAsync_IfDifferentUser_ReturnsNull( + SutProvider sutProvider, + AuthRequest authRequest, + Guid authRequestId, + Guid userId) + { + sutProvider.GetDependency() + .GetByIdAsync(authRequestId) + .Returns(authRequest); + + var foundAuthRequest = await sutProvider.Sut.GetAuthRequestAsync(authRequestId, userId); + + Assert.Null(foundAuthRequest); + } + + [Theory, BitAutoData] + public async Task GetAuthRequestAsync_IfSameUser_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequest authRequest, + Guid authRequestId) + { + sutProvider.GetDependency() + .GetByIdAsync(authRequestId) + .Returns(authRequest); + + var foundAuthRequest = await sutProvider.Sut.GetAuthRequestAsync(authRequestId, authRequest.UserId); + + Assert.NotNull(foundAuthRequest); + } + + [Theory, BitAutoData] + public async Task GetValidatedAuthRequestAsync_IfCodeNotValid_ReturnsNull( + SutProvider sutProvider, + AuthRequest authRequest, + string accessCode) + { + authRequest.CreationDate = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var foundAuthRequest = await sutProvider.Sut.GetValidatedAuthRequestAsync(authRequest.Id, accessCode); + + Assert.Null(foundAuthRequest); + } + + [Theory, BitAutoData] + public async Task GetValidatedAuthRequestAsync_IfExpired_ReturnsNull( + SutProvider sutProvider, + AuthRequest authRequest) + { + authRequest.CreationDate = DateTime.UtcNow.AddHours(-1); + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var foundAuthRequest = await sutProvider.Sut.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode); + + Assert.Null(foundAuthRequest); + } + + [Theory, BitAutoData] + public async Task GetValidatedAuthRequestAsync_IfValid_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequest authRequest) + { + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-2); + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var foundAuthRequest = await sutProvider.Sut.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode); + + Assert.NotNull(foundAuthRequest); + } + + [Theory, BitAutoData] + public async Task CreateAuthRequestAsync_NoUser_ThrowsNotFound( + SutProvider sutProvider, + AuthRequestCreateRequestModel createModel) + { + sutProvider.GetDependency() + .GetByEmailAsync(createModel.Email) + .Returns((User?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAuthRequestAsync(createModel)); + } + + [Theory, BitAutoData] + public async Task CreateAuthRequestAsync_NoKnownDevice_ThrowsBadRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel createModel, + User user) + { + user.Email = createModel.Email; + + sutProvider.GetDependency() + .GetByEmailAsync(createModel.Email) + .Returns(user); + + sutProvider.GetDependency() + .DeviceType + .Returns(DeviceType.Android); + + sutProvider.GetDependency() + .PasswordlessAuth.KnownDevicesOnly + .Returns(true); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAuthRequestAsync(createModel)); + } + + [Theory, BitAutoData] + public async Task CreateAuthRequestAsync_CreatesAuthRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel createModel, + User user) + { + user.Email = createModel.Email; + + sutProvider.GetDependency() + .GetByEmailAsync(createModel.Email) + .Returns(user); + + sutProvider.GetDependency() + .DeviceType + .Returns(DeviceType.Android); + + sutProvider.GetDependency() + .PasswordlessAuth.KnownDevicesOnly + .Returns(false); + + await sutProvider.Sut.CreateAuthRequestAsync(createModel); + + await sutProvider.GetDependency() + .Received() + .PushAuthRequestAsync(Arg.Any()); + + await sutProvider.GetDependency() + .Received() + .CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAuthRequestAsync_NoDeviceType_ThrowsBadRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel createModel, + User user) + { + user.Email = createModel.Email; + + sutProvider.GetDependency() + .GetByEmailAsync(createModel.Email) + .Returns(user); + + sutProvider.GetDependency() + .DeviceType + .Returns((DeviceType?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAuthRequestAsync(createModel)); + } + + [Theory, BitAutoData] + public async Task UpdateAuthRequestAsync_ValidResponse_SendsResponse( + SutProvider sutProvider, + AuthRequest authRequest) + { + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10); + authRequest.Approved = null; + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var device = new Device + { + Id = Guid.NewGuid(), + Identifier = "test_identifier", + }; + + sutProvider.GetDependency() + .GetByIdentifierAsync(device.Identifier, authRequest.UserId) + .Returns(device); + + var updateModel = new AuthRequestUpdateRequestModel + { + Key = "test_key", + DeviceIdentifier = "test_identifier", + RequestApproved = true, + MasterPasswordHash = "my_hash", + }; + + var udpatedAuthRequest = await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel); + + Assert.Equal("my_hash", udpatedAuthRequest.MasterPasswordHash); + + await sutProvider.GetDependency() + .Received() + .ReplaceAsync(udpatedAuthRequest); + + await sutProvider.GetDependency() + .Received() + .PushAuthRequestResponseAsync(udpatedAuthRequest); + } + + [Theory, BitAutoData] + public async Task UpdateAuthRequestAsync_ResponseNotApproved_DoesNotLeakRejection( + SutProvider sutProvider, + AuthRequest authRequest) + { + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10); + authRequest.Approved = null; + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var device = new Device + { + Id = Guid.NewGuid(), + Identifier = "test_identifier", + }; + + sutProvider.GetDependency() + .GetByIdentifierAsync(device.Identifier, authRequest.UserId) + .Returns(device); + + var updateModel = new AuthRequestUpdateRequestModel + { + Key = "test_key", + DeviceIdentifier = "test_identifier", + RequestApproved = false, + MasterPasswordHash = "my_hash", + }; + + var udpatedAuthRequest = await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel); + + Assert.Equal(udpatedAuthRequest.MasterPasswordHash, authRequest.MasterPasswordHash); + + await sutProvider.GetDependency() + .Received() + .ReplaceAsync(udpatedAuthRequest); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushAuthRequestResponseAsync(udpatedAuthRequest); + } + + [Theory, BitAutoData] + public async Task UpdateAuthRequestAsync_InvalidUser_ThrowsNotFound( + SutProvider sutProvider, + AuthRequest authRequest, + Guid userId) + { + // Give it a recent creation date so that it is valid + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10); + authRequest.Approved = false; + + // Auth request should not be null + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var updateModel = new AuthRequestUpdateRequestModel + { + Key = "test_key", + DeviceIdentifier = "test_identifier", + RequestApproved = true, + MasterPasswordHash = "my_hash", + }; + + // Give it a randomly generated userId such that it won't be valid for the AuthRequest + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, userId, updateModel)); + } + + [Theory, BitAutoData] + public async Task UpdateAuthRequestAsync_OldAuthRequest_ThrowsNotFound( + SutProvider sutProvider, + AuthRequest authRequest) + { + // AuthRequest's have a valid lifetime of only 15 minutes, make it older than that + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-16); + authRequest.Approved = false; + + // Auth request should not be null + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var updateModel = new AuthRequestUpdateRequestModel + { + Key = "test_key", + DeviceIdentifier = "test_identifier", + RequestApproved = true, + MasterPasswordHash = "my_hash", + }; + + // Give it a randomly generated userId such that it won't be valid for the AuthRequest + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel)); + } + + [Theory, BitAutoData] + public async Task UpdateAuthRequestAsync_InvalidDeviceIdentifier_ThrowsBadRequest( + SutProvider sutProvider, + AuthRequest authRequest) + { + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10); + authRequest.Approved = null; + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + sutProvider.GetDependency() + .GetByIdentifierAsync(Arg.Any(), authRequest.UserId) + .Returns((Device?)null); + + var updateModel = new AuthRequestUpdateRequestModel + { + Key = "test_key", + DeviceIdentifier = "invalid_identifier", + RequestApproved = true, + MasterPasswordHash = "my_hash", + }; + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel)); + } + + [Theory, BitAutoData] + public async Task UpdateAuthRequestAsync_AlreadyApprovedOrRejected_ThrowsDuplicateAuthRequestException( + SutProvider sutProvider, + AuthRequest authRequest) + { + // Set CreationDate to a valid recent value and Approved to a non-null value + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10); + authRequest.Approved = true; + + sutProvider.GetDependency() + .GetByIdAsync(authRequest.Id) + .Returns(authRequest); + + var device = new Device + { + Id = Guid.NewGuid(), + Identifier = "test_identifier", + }; + + sutProvider.GetDependency() + .GetByIdentifierAsync(device.Identifier, authRequest.UserId) + .Returns(device); + + var updateModel = new AuthRequestUpdateRequestModel + { + Key = "test_key", + DeviceIdentifier = "test_identifier", + RequestApproved = true, + MasterPasswordHash = "my_hash", + }; + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel)); + } + +}