1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[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
This commit is contained in:
Ike 2024-10-10 17:26:17 -07:00 committed by GitHub
parent 96f58dc309
commit 22dd957543
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 446 additions and 139 deletions

View File

@ -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<T> where T : class
{
private UserManager<User> _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<T> where T : class
public BaseRequestValidator(
UserManager<User> 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<T> 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<T> 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<T> 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<T> 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<T> 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<bool> VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType type,
string token)
{
@ -537,51 +507,6 @@ public abstract class BaseRequestValidator<T> where T : class
}
}
protected async Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request) =>
(await GetKnownDeviceAsync(user, request)) != default;
protected async Task<Device> GetKnownDeviceAsync(User user, ValidatedTokenRequest request)
{
if (user == null)
{
return default;
}
return await _deviceRepository.GetByIdentifierAsync(GetDeviceFromRequest(request).Identifier, user.Id);
}
private async Task<Device> 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<DisplayAttribute>()?.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

View File

@ -29,8 +29,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
public CustomTokenRequestValidator(
UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IDeviceValidator deviceValidator,
IUserService userService,
IEventService eventService,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
@ -48,7 +47,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> 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<CustomTokenReque
{
context.Result.CustomResponse = new Dictionary<string, object> { { "encrypted_payload", payload } };
}
return;
}
await ValidateAsync(context, context.Result.ValidatedRequest,
new CustomValidatorRequestContext { KnownDevice = true });
}
@ -103,7 +99,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{
validatorContext.User = await _userManager.FindByEmailAsync(email);
}
return validatorContext.User != null;
}
@ -121,7 +116,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.ValidatedRequest.ClientClaims.Add(claim);
}
}
if (context.Result.CustomResponse == null || user.MasterPassword != null)
{
return Task.CompletedTask;
@ -138,7 +132,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.CustomResponse["ApiUseKeyConnector"] = true;
context.Result.CustomResponse["ResetMasterPassword"] = false;
}
return Task.CompletedTask;
}
@ -150,13 +143,11 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{
return Task.CompletedTask;
}
if (userDecryptionOptions is { KeyConnectorOption: { } })
{
context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl;
context.Result.CustomResponse["ResetMasterPassword"] = false;
}
return Task.CompletedTask;
}

View File

@ -0,0 +1,109 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
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 Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer;
public interface IDeviceValidator
{
/// <summary>
/// Save a device to the database. If the device is already known, it will be returned.
/// </summary>
/// <param name="user">The user is assumed NOT null, still going to check though</param>
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request);
/// <summary>
/// Check if a device is known to the user.
/// </summary>
/// <param name="user">current user trying to authenticate</param>
/// <param name="request">contains raw information that is parsed about the device</param>
/// <returns>true if the device is known, false if it is not</returns>
Task<bool> 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<Device> 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<DisplayAttribute>()?.GetName();
if (!_globalSettings.DisableEmailNewDevice)
{
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
_currentContext.IpAddress);
}
}
return device;
}
return existingDevice;
}
return null;
}
public async Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request) =>
(await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default;
private async Task<Device> 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
};
}
}

View File

@ -25,12 +25,12 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
private readonly ICurrentContext _currentContext;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IDeviceValidator _deviceValidator;
public ResourceOwnerPasswordValidator(
UserManager<User> 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<ResourceOwner
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
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, userDecryptionOptionsBuilder)
@ -57,6 +57,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
_currentContext = currentContext;
_captchaValidationService = captchaValidationService;
_authRequestRepository = authRequestRepository;
_deviceValidator = deviceValidator;
}
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
@ -72,7 +73,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
var validatorContext = new CustomValidatorRequestContext
{
User = user,
KnownDevice = await KnownDeviceAsync(user, context.Request)
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request),
};
string bypassToken = null;
if (!validatorContext.KnownDevice &&

View File

@ -27,13 +27,13 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
private readonly IDeviceValidator _deviceValidator;
public WebAuthnGrantValidator(
UserManager<User> 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<ExtensionGrantValidat
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
)
: 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, userDecryptionOptionsBuilder)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
_deviceValidator = deviceValidator;
}
string IExtensionGrantValidator.GrantType => "webauthn";
@ -87,7 +88,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
var validatorContext = new CustomValidatorRequestContext
{
User = user,
KnownDevice = await KnownDeviceAsync(user, context.Request)
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request)
};
UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);

View File

@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<StaticClientStore>();
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
services.AddTransient<IDeviceValidator, DeviceValidator>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services

View File

@ -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<IdentityApplica
private readonly IdentityApplicationFactory _factory;
private readonly UserManager<User> _userManager;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IDeviceService _deviceService;
public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory)
{
@ -28,13 +33,13 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
_userManager = _factory.GetService<UserManager<User>>();
_authRequestRepository = _factory.GetService<IAuthRequestRepository>();
_deviceService = _factory.GetService<IDeviceService>();
}
[Fact]
public async Task ValidateAsync_Success()
{
// Arrange
// Arrange
await EnsureUserCreatedAsync();
// Act
@ -53,7 +58,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
[Fact]
public async Task ValidateAsync_AuthEmailHeaderInvalid_InvalidGrantResponse()
{
// Arrange
// Arrange
await EnsureUserCreatedAsync();
// Act
@ -88,12 +93,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
}
/// <summary>
/// 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<User> 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<User> thus causing the
/// RegisterAsync() to create a the user in a different UserStore than the one the
/// UserManager<User> has access to. This is an assumption made from observing the behavior while
/// writing theses tests. I could be wrong.
///
/// UserManager<User> 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.
/// </summary>
@ -102,10 +107,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
[Theory, BitAutoData]
public async Task ValidateAsync_BadPassword_Failure(string badPassword)
{
// Arrange
// Arrange
await EnsureUserCreatedAsync();
// Verify the User is not null to ensure the failure is due to bad password
Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername));
// Act
var context = await _factory.Server.PostAsync("/connect/token",
@ -113,8 +119,6 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
context => context.SetAuthEmail(DefaultUsername));
// Assert
Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername));
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
@ -200,8 +204,8 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
// Assert
/*
An improvement on the current failure flow would be to document which part of
the flow failed since all of the failures are basically the same.
An improvement on the current failure flow would be to document which part of
the flow failed since all of the failures are basically the same.
This doesn't build confidence in the tests.
*/
@ -213,6 +217,43 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
Assert.Equal("Username or password is incorrect. Try again.", errorMessage);
}
[Fact]
public async Task ValidateAsync_DeviceSaveAsync_ReturnsNullDevice_ErrorResult()
{
// Arrange
var factory = new IdentityApplicationFactory();
// Stub DeviceValidator
factory.SubstituteService<IDeviceValidator>(sub =>
{
sub.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(null as Device);
});
// Add User
await factory.RegisterAsync(new RegisterRequestModel
{
Email = DefaultUsername,
MasterPasswordHash = DefaultPassword
});
var userManager = factory.GetService<UserManager<User>>();
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<JsonDocument>(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;

View File

@ -29,10 +29,9 @@ namespace Bit.Identity.Test.IdentityServer;
public class BaseRequestValidatorTests
{
private UserManager<User> _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<IDeviceRepository>();
_deviceService = Substitute.For<IDeviceService>();
_userService = Substitute.For<IUserService>();
_eventService = Substitute.For<IEventService>();
_deviceValidator = Substitute.For<IDeviceValidator>();
_organizationDuoWebTokenProvider = Substitute.For<IOrganizationDuoWebTokenProvider>();
_duoWebV4SDKService = Substitute.For<ITemporaryDuoWebV4SDKService>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
@ -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<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(device);
// Act
await _sut.ValidateAsync(context);
// Assert
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
context.CustomValidatorRequestContext.User.Email, "Android", Arg.Any<DateTime>(), Arg.Any<string>()
);
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<Guid>())
.Returns(new Device() { Identifier = "DeviceIdentifier" });
_deviceValidator.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(device);
// Act
await _sut.ValidateAsync(context);

View File

@ -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<IDeviceService>();
_deviceRepository = Substitute.For<IDeviceRepository>();
_globalSettings = new GlobalSettings();
_mailService = Substitute.For<IMailService>();
_currentContext = Substitute.For<ICurrentContext>();
_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<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[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<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[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<DateTime>(), Arg.Any<string>());
}
[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<DateTime>(), Arg.Any<string>());
}
[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<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[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<string>(), Arg.Any<Guid>()).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<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[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<string>(), Arg.Any<Guid>())
.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<string>(), Arg.Any<Guid>())
.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;
}
}

View File

@ -51,10 +51,9 @@ IBaseRequestValidatorTestWrapper
public bool isValid { get; set; }
public BaseRequestValidatorTestWrapper(
UserManager<User> 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,

View File

@ -145,7 +145,10 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
// 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" },
});
});