From 22dd9575438bf7858cff9b3575850f3b3fa5fdd7 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Thu, 10 Oct 2024 17:26:17 -0700 Subject: [PATCH] [PM-10742] Pull Device verification into testable service (#4851) * initial device removal * Unit Testing * Added unit tests fixed validator null checks * Finalized tests * formatting * fixed test * lint * addressing review notes * comments --- .../IdentityServer/BaseRequestValidator.cs | 87 +----- .../CustomTokenRequestValidator.cs | 13 +- .../IdentityServer/DeviceValidator.cs | 109 ++++++++ .../ResourceOwnerPasswordValidator.cs | 9 +- .../IdentityServer/WebAuthnGrantValidator.cs | 9 +- .../Utilities/ServiceCollectionExtensions.cs | 1 + .../ResourceOwnerPasswordValidatorTests.cs | 67 ++++- .../BaseRequestValidatorTests.cs | 32 +-- .../IdentityServer/DeviceValidatorTests.cs | 247 ++++++++++++++++++ .../BaseRequestValidatorTestWrapper.cs | 6 +- .../Factories/WebApplicationFactoryBase.cs | 5 +- 11 files changed, 446 insertions(+), 139 deletions(-) create mode 100644 src/Identity/IdentityServer/DeviceValidator.cs create mode 100644 test/Identity.Test/IdentityServer/DeviceValidatorTests.cs diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index 881ae4d49..8129a1a10 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -1,6 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using System.Security.Claims; +using System.Security.Claims; using System.Text.Json; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -33,9 +31,8 @@ namespace Bit.Identity.IdentityServer; public abstract class BaseRequestValidator where T : class { private UserManager _userManager; - private readonly IDeviceRepository _deviceRepository; - private readonly IDeviceService _deviceService; private readonly IEventService _eventService; + private readonly IDeviceValidator _deviceValidator; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService; private readonly IOrganizationRepository _organizationRepository; @@ -56,10 +53,9 @@ public abstract class BaseRequestValidator where T : class public BaseRequestValidator( UserManager userManager, - IDeviceRepository deviceRepository, - IDeviceService deviceService, IUserService userService, IEventService eventService, + IDeviceValidator deviceValidator, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITemporaryDuoWebV4SDKService duoWebV4SDKService, IOrganizationRepository organizationRepository, @@ -77,10 +73,9 @@ public abstract class BaseRequestValidator where T : class IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) { _userManager = userManager; - _deviceRepository = deviceRepository; - _deviceService = deviceService; _userService = userService; _eventService = eventService; + _deviceValidator = deviceValidator; _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider; _duoWebV4SDKService = duoWebV4SDKService; _organizationRepository = organizationRepository; @@ -131,9 +126,7 @@ public abstract class BaseRequestValidator where T : class var (isTwoFactorRequired, twoFactorOrganization) = await RequiresTwoFactorAsync(user, request); if (isTwoFactorRequired) { - // Just defaulting it - var twoFactorProviderType = TwoFactorProviderType.Authenticator; - if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out twoFactorProviderType)) + if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) { await BuildTwoFactorResultAsync(user, twoFactorOrganization, context); return; @@ -162,7 +155,6 @@ public abstract class BaseRequestValidator where T : class twoFactorToken = null; } - // Force legacy users to the web for migration if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers)) { @@ -176,7 +168,7 @@ public abstract class BaseRequestValidator where T : class // Returns true if can finish validation process if (await IsValidAuthTypeAsync(user, request.GrantType)) { - var device = await SaveDeviceAsync(user, request); + var device = await _deviceValidator.SaveDeviceAsync(user, request); if (device == null) { await BuildErrorResultAsync("No device information provided.", false, context, user); @@ -393,28 +385,6 @@ public abstract class BaseRequestValidator where T : class orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; } - private Device GetDeviceFromRequest(ValidatedRequest request) - { - var deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString(); - var deviceType = request.Raw["DeviceType"]?.ToString(); - var deviceName = request.Raw["DeviceName"]?.ToString(); - var devicePushToken = request.Raw["DevicePushToken"]?.ToString(); - - if (string.IsNullOrWhiteSpace(deviceIdentifier) || string.IsNullOrWhiteSpace(deviceType) || - string.IsNullOrWhiteSpace(deviceName) || !Enum.TryParse(deviceType, out DeviceType type)) - { - return null; - } - - return new Device - { - Identifier = deviceIdentifier, - Name = deviceName, - Type = type, - PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken - }; - } - private async Task VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType type, string token) { @@ -537,51 +507,6 @@ public abstract class BaseRequestValidator where T : class } } - protected async Task KnownDeviceAsync(User user, ValidatedTokenRequest request) => - (await GetKnownDeviceAsync(user, request)) != default; - - protected async Task GetKnownDeviceAsync(User user, ValidatedTokenRequest request) - { - if (user == null) - { - return default; - } - - return await _deviceRepository.GetByIdentifierAsync(GetDeviceFromRequest(request).Identifier, user.Id); - } - - private async Task SaveDeviceAsync(User user, ValidatedTokenRequest request) - { - var device = GetDeviceFromRequest(request); - if (device != null) - { - var existingDevice = await GetKnownDeviceAsync(user, request); - if (existingDevice == null) - { - device.UserId = user.Id; - await _deviceService.SaveAsync(device); - - var now = DateTime.UtcNow; - if (now - user.CreationDate > TimeSpan.FromMinutes(10)) - { - var deviceType = device.Type.GetType().GetMember(device.Type.ToString()) - .FirstOrDefault()?.GetCustomAttribute()?.GetName(); - if (!_globalSettings.DisableEmailNewDevice) - { - await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, - CurrentContext.IpAddress); - } - } - - return device; - } - - return existingDevice; - } - - return null; - } - private async Task ResetFailedAuthDetailsAsync(User user) { // Early escape if db hit not necessary diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index 3af1337ee..0d7a92c8a 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -29,8 +29,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator userManager, - IDeviceRepository deviceRepository, - IDeviceService deviceService, + IDeviceValidator deviceValidator, IUserService userService, IEventService eventService, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, @@ -48,7 +47,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory, IFeatureService featureService, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) - : base(userManager, deviceRepository, deviceService, userService, eventService, + : base(userManager, userService, eventService, deviceValidator, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, @@ -83,11 +82,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator { { "encrypted_payload", payload } }; } - - return; } - await ValidateAsync(context, context.Result.ValidatedRequest, new CustomValidatorRequestContext { KnownDevice = true }); } @@ -103,7 +99,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator + /// Save a device to the database. If the device is already known, it will be returned. + /// + /// The user is assumed NOT null, still going to check though + /// Duende Validated Request that contains the data to create the device object + /// Returns null if user or device is malformed; The existing device if already in DB; a new device login + Task SaveDeviceAsync(User user, ValidatedTokenRequest request); + /// + /// Check if a device is known to the user. + /// + /// current user trying to authenticate + /// contains raw information that is parsed about the device + /// true if the device is known, false if it is not + Task KnownDeviceAsync(User user, ValidatedTokenRequest request); +} + +public class DeviceValidator( + IDeviceService deviceService, + IDeviceRepository deviceRepository, + GlobalSettings globalSettings, + IMailService mailService, + ICurrentContext currentContext) : IDeviceValidator +{ + private readonly IDeviceService _deviceService = deviceService; + private readonly IDeviceRepository _deviceRepository = deviceRepository; + private readonly GlobalSettings _globalSettings = globalSettings; + private readonly IMailService _mailService = mailService; + private readonly ICurrentContext _currentContext = currentContext; + + public async Task SaveDeviceAsync(User user, ValidatedTokenRequest request) + { + var device = GetDeviceFromRequest(request); + if (device != null && user != null) + { + var existingDevice = await GetKnownDeviceAsync(user, device); + if (existingDevice == null) + { + device.UserId = user.Id; + await _deviceService.SaveAsync(device); + + // This makes sure the user isn't sent a "new device" email on their first login + var now = DateTime.UtcNow; + if (now - user.CreationDate > TimeSpan.FromMinutes(10)) + { + var deviceType = device.Type.GetType().GetMember(device.Type.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName(); + if (!_globalSettings.DisableEmailNewDevice) + { + await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, + _currentContext.IpAddress); + } + } + return device; + } + return existingDevice; + } + return null; + } + + public async Task KnownDeviceAsync(User user, ValidatedTokenRequest request) => + (await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default; + + private async Task GetKnownDeviceAsync(User user, Device device) + { + if (user == null || device == null) + { + return default; + } + return await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id); + } + + private static Device GetDeviceFromRequest(ValidatedRequest request) + { + var deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString(); + var requestDeviceType = request.Raw["DeviceType"]?.ToString(); + var deviceName = request.Raw["DeviceName"]?.ToString(); + var devicePushToken = request.Raw["DevicePushToken"]?.ToString(); + + if (string.IsNullOrWhiteSpace(deviceIdentifier) || + string.IsNullOrWhiteSpace(requestDeviceType) || + string.IsNullOrWhiteSpace(deviceName) || + !Enum.TryParse(requestDeviceType, out DeviceType parsedDeviceType)) + { + return null; + } + + return new Device + { + Identifier = deviceIdentifier, + Name = deviceName, + Type = parsedDeviceType, + PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken + }; + } +} diff --git a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs index cb63bd94e..08560e240 100644 --- a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -25,12 +25,12 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator userManager, - IDeviceRepository deviceRepository, - IDeviceService deviceService, IUserService userService, IEventService eventService, + IDeviceValidator deviceValidator, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITemporaryDuoWebV4SDKService duoWebV4SDKService, IOrganizationRepository organizationRepository, @@ -48,7 +48,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator _assertionOptionsDataProtector; private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand; + private readonly IDeviceValidator _deviceValidator; public WebAuthnGrantValidator( UserManager userManager, - IDeviceRepository deviceRepository, - IDeviceService deviceService, IUserService userService, IEventService eventService, + IDeviceValidator deviceValidator, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITemporaryDuoWebV4SDKService duoWebV4SDKService, IOrganizationRepository organizationRepository, @@ -52,13 +52,14 @@ public class WebAuthnGrantValidator : BaseRequestValidator "webauthn"; @@ -87,7 +88,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs index fac271b14..91d0ee01f 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs @@ -4,11 +4,15 @@ using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Identity.IdentityServer; using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; +using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; +using NSubstitute; using Xunit; namespace Bit.Identity.IntegrationTest.RequestValidation; @@ -21,6 +25,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture _userManager; private readonly IAuthRequestRepository _authRequestRepository; + private readonly IDeviceService _deviceService; public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory) { @@ -28,13 +33,13 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); _authRequestRepository = _factory.GetService(); - + _deviceService = _factory.GetService(); } [Fact] public async Task ValidateAsync_Success() { - // Arrange + // Arrange await EnsureUserCreatedAsync(); // Act @@ -53,7 +58,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture - /// I would have liked to spy into the IUserService but by spying into the IUserService it - /// creates a Singleton that is not available to the UserManager thus causing the + /// I would have liked to spy into the IUserService but by spying into the IUserService it + /// creates a Singleton that is not available to the UserManager thus causing the /// RegisterAsync() to create a the user in a different UserStore than the one the - /// UserManager has access to. This is an assumption made from observing the behavior while - /// writing theses tests. I could be wrong. - /// + /// UserManager has access to (This is an assumption made from observing the behavior while + /// writing theses tests, I could be wrong). + /// /// For the time being, verifying that the user is not null confirms that the failure is due to /// a bad password. /// @@ -102,10 +107,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture context.SetAuthEmail(DefaultUsername)); // Assert - Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername)); - var body = await AssertHelper.AssertResponseTypeIs(context); var root = body.RootElement; @@ -200,8 +204,8 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(sub => + { + sub.SaveDeviceAsync(Arg.Any(), Arg.Any()) + .Returns(null as Device); + }); + + // Add User + await factory.RegisterAsync(new RegisterRequestModel + { + Email = DefaultUsername, + MasterPasswordHash = DefaultPassword + }); + var userManager = factory.GetService>(); + var user = await userManager.FindByEmailAsync(DefaultUsername); + Assert.NotNull(user); + + // Act + var context = await factory.Server.PostAsync("/connect/token", + GetFormUrlEncodedContent(), + context => context.SetAuthEmail(DefaultUsername)); + + // Assert + var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + var errorModel = AssertHelper.AssertJsonProperty(root, "ErrorModel", JsonValueKind.Object); + var errorMessage = AssertHelper.AssertJsonProperty(errorModel, "Message", JsonValueKind.String).GetString(); + Assert.Equal("No device information provided.", errorMessage); + } + private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory = null) { factory ??= _factory; diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index c1d34e1b0..39b7edf8d 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -29,10 +29,9 @@ namespace Bit.Identity.Test.IdentityServer; public class BaseRequestValidatorTests { private UserManager _userManager; - private readonly IDeviceRepository _deviceRepository; - private readonly IDeviceService _deviceService; private readonly IUserService _userService; private readonly IEventService _eventService; + private readonly IDeviceValidator _deviceValidator; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService; private readonly IOrganizationRepository _organizationRepository; @@ -53,10 +52,9 @@ public class BaseRequestValidatorTests public BaseRequestValidatorTests() { - _deviceRepository = Substitute.For(); - _deviceService = Substitute.For(); _userService = Substitute.For(); _eventService = Substitute.For(); + _deviceValidator = Substitute.For(); _organizationDuoWebTokenProvider = Substitute.For(); _duoWebV4SDKService = Substitute.For(); _organizationRepository = Substitute.For(); @@ -76,10 +74,9 @@ public class BaseRequestValidatorTests _sut = new BaseRequestValidatorTestWrapper( _userManager, - _deviceRepository, - _deviceService, _userService, _eventService, + _deviceValidator, _organizationDuoWebTokenProvider, _duoWebV4SDKService, _organizationRepository, @@ -228,7 +225,8 @@ public class BaseRequestValidatorTests public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) + GrantValidationResult grantResult, + Device device) { // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -240,18 +238,13 @@ public class BaseRequestValidatorTests _globalSettings.DisableEmailNewDevice = false; context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device - context.ValidatedTokenRequest.Raw["DeviceIdentifier"] = "DeviceIdentifier"; - context.ValidatedTokenRequest.Raw["DeviceType"] = "Android"; // This needs to be an actual Type - context.ValidatedTokenRequest.Raw["DeviceName"] = "DeviceName"; - context.ValidatedTokenRequest.Raw["DevicePushToken"] = "DevicePushToken"; + _deviceValidator.SaveDeviceAsync(Arg.Any(), Arg.Any()) + .Returns(device); // Act await _sut.ValidateAsync(context); // Assert - await _mailService.Received(1).SendNewDeviceLoggedInEmail( - context.CustomValidatorRequestContext.User.Email, "Android", Arg.Any(), Arg.Any() - ); Assert.False(context.GrantResult.IsError); } @@ -262,7 +255,8 @@ public class BaseRequestValidatorTests public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) + GrantValidationResult grantResult, + Device device) { // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -274,13 +268,9 @@ public class BaseRequestValidatorTests _globalSettings.DisableEmailNewDevice = false; context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device - context.ValidatedTokenRequest.Raw["DeviceIdentifier"] = "DeviceIdentifier"; - context.ValidatedTokenRequest.Raw["DeviceType"] = "Android"; // This needs to be an actual Type - context.ValidatedTokenRequest.Raw["DeviceName"] = "DeviceName"; - context.ValidatedTokenRequest.Raw["DevicePushToken"] = "DevicePushToken"; - _deviceRepository.GetByIdentifierAsync("DeviceIdentifier", Arg.Any()) - .Returns(new Device() { Identifier = "DeviceIdentifier" }); + _deviceValidator.SaveDeviceAsync(Arg.Any(), Arg.Any()) + .Returns(device); // Act await _sut.ValidateAsync(context); diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs new file mode 100644 index 000000000..1f4d5a807 --- /dev/null +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -0,0 +1,247 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Identity.IdentityServer; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; +using AuthFixtures = Bit.Identity.Test.AutoFixture; + +namespace Bit.Identity.Test.IdentityServer; + +public class DeviceValidatorTests +{ + private readonly IDeviceService _deviceService; + private readonly IDeviceRepository _deviceRepository; + private readonly GlobalSettings _globalSettings; + private readonly IMailService _mailService; + private readonly ICurrentContext _currentContext; + private readonly DeviceValidator _sut; + + public DeviceValidatorTests() + { + _deviceService = Substitute.For(); + _deviceRepository = Substitute.For(); + _globalSettings = new GlobalSettings(); + _mailService = Substitute.For(); + _currentContext = Substitute.For(); + _sut = new DeviceValidator( + _deviceService, + _deviceRepository, + _globalSettings, + _mailService, + _currentContext); + } + + [Theory] + [BitAutoData] + public async void SaveDeviceAsync_DeviceNull_ShouldReturnNull( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user) + { + // Arrange + request.Raw["DeviceIdentifier"] = null; + + // Act + var device = await _sut.SaveDeviceAsync(user, request); + + // Assert + Assert.Null(device); + await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void SaveDeviceAsync_UserIsNull_ShouldReturnNull( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) + { + // Arrange + request = AddValidDeviceToRequest(request); + + // Act + var device = await _sut.SaveDeviceAsync(null, request); + + // Assert + Assert.Null(device); + await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendsEmail( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user) + { + // Arrange + request = AddValidDeviceToRequest(request); + + user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11); + _globalSettings.DisableEmailNewDevice = false; + + // Act + var device = await _sut.SaveDeviceAsync(user, request); + + // Assert + Assert.NotNull(device); + Assert.Equal(user.Id, device.UserId); + Assert.Equal("DeviceIdentifier", device.Identifier); + Assert.Equal(DeviceType.Android, device.Type); + await _mailService.Received(1).SendNewDeviceLoggedInEmail( + user.Email, "Android", Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendEmailFalse( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user) + { + // Arrange + request = AddValidDeviceToRequest(request); + + user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11); + _globalSettings.DisableEmailNewDevice = true; + + // Act + var device = await _sut.SaveDeviceAsync(user, request); + + // Assert + Assert.NotNull(device); + Assert.Equal(user.Id, device.UserId); + Assert.Equal("DeviceIdentifier", device.Identifier); + Assert.Equal(DeviceType.Android, device.Type); + await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail( + user.Email, "Android", Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void SaveDeviceAsync_DeviceIsKnown_ShouldReturnDevice( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user, + Device device) + { + // Arrange + request = AddValidDeviceToRequest(request); + + device.UserId = user.Id; + device.Identifier = "DeviceIdentifier"; + device.Type = DeviceType.Android; + device.Name = "DeviceName"; + device.PushToken = "DevicePushToken"; + _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id).Returns(device); + + // Act + var resultDevice = await _sut.SaveDeviceAsync(user, request); + + // Assert + Assert.Equal(device, resultDevice); + await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void SaveDeviceAsync_NewUser_DeviceUnknown_ShouldSaveDevice_NoEmail( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user) + { + // Arrange + request = AddValidDeviceToRequest(request); + user.CreationDate = DateTime.UtcNow; + _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any()).Returns(null as Device); + + // Act + var device = await _sut.SaveDeviceAsync(user, request); + + // Assert + Assert.NotNull(device); + Assert.Equal(user.Id, device.UserId); + Assert.Equal("DeviceIdentifier", device.Identifier); + Assert.Equal(DeviceType.Android, device.Type); + await _deviceService.Received(1).SaveAsync(device); + await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void KnownDeviceAsync_UserNull_ReturnsFalse( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) + { + // Arrange + request = AddValidDeviceToRequest(request); + + // Act + var result = await _sut.KnownDeviceAsync(null, request); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public async void KnownDeviceAsync_DeviceNull_ReturnsFalse( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user) + { + // Arrange + // Device raw data is null which will cause the device to be null + + // Act + var result = await _sut.KnownDeviceAsync(user, request); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public async void KnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user) + { + // Arrange + request = AddValidDeviceToRequest(request); + _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any()) + .Returns(null as Device); + // Act + var result = await _sut.KnownDeviceAsync(user, request); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public async void KnownDeviceAsync_UserAndDeviceValid_ReturnsTrue( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + User user, + Device device) + { + // Arrange + request = AddValidDeviceToRequest(request); + _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any()) + .Returns(device); + // Act + var result = await _sut.KnownDeviceAsync(user, request); + + // Assert + Assert.True(result); + } + + private ValidatedTokenRequest AddValidDeviceToRequest(ValidatedTokenRequest request) + { + request.Raw["DeviceIdentifier"] = "DeviceIdentifier"; + request.Raw["DeviceType"] = "Android"; + request.Raw["DeviceName"] = "DeviceName"; + request.Raw["DevicePushToken"] = "DevicePushToken"; + return request; + } +} diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index e525d0de7..26043fd59 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -51,10 +51,9 @@ IBaseRequestValidatorTestWrapper public bool isValid { get; set; } public BaseRequestValidatorTestWrapper( UserManager userManager, - IDeviceRepository deviceRepository, - IDeviceService deviceService, IUserService userService, IEventService eventService, + IDeviceValidator deviceValidator, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITemporaryDuoWebV4SDKService duoWebV4SDKService, IOrganizationRepository organizationRepository, @@ -72,10 +71,9 @@ IBaseRequestValidatorTestWrapper IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base( userManager, - deviceRepository, - deviceService, userService, eventService, + deviceValidator, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index e0fcc0e5e..aafe86d56 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -145,7 +145,10 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory // Email Verification { "globalSettings:enableEmailVerification", "true" }, { "globalSettings:disableUserRegistration", "false" }, - { "globalSettings:launchDarkly:flagValues:email-verification", "true" } + { "globalSettings:launchDarkly:flagValues:email-verification", "true" }, + + // New Device Verification + { "globalSettings:disableEmailNewDevice", "false" }, }); });