1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-20 19:14:32 -05: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.Security.Claims;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
@ -33,9 +31,8 @@ namespace Bit.Identity.IdentityServer;
public abstract class BaseRequestValidator<T> where T : class public abstract class BaseRequestValidator<T> where T : class
{ {
private UserManager<User> _userManager; private UserManager<User> _userManager;
private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider;
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService; private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
@ -56,10 +53,9 @@ public abstract class BaseRequestValidator<T> where T : class
public BaseRequestValidator( public BaseRequestValidator(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService, ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -77,10 +73,9 @@ public abstract class BaseRequestValidator<T> where T : class
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
{ {
_userManager = userManager; _userManager = userManager;
_deviceRepository = deviceRepository;
_deviceService = deviceService;
_userService = userService; _userService = userService;
_eventService = eventService; _eventService = eventService;
_deviceValidator = deviceValidator;
_organizationDuoWebTokenProvider = organizationDuoWebTokenProvider; _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider;
_duoWebV4SDKService = duoWebV4SDKService; _duoWebV4SDKService = duoWebV4SDKService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -131,9 +126,7 @@ public abstract class BaseRequestValidator<T> where T : class
var (isTwoFactorRequired, twoFactorOrganization) = await RequiresTwoFactorAsync(user, request); var (isTwoFactorRequired, twoFactorOrganization) = await RequiresTwoFactorAsync(user, request);
if (isTwoFactorRequired) if (isTwoFactorRequired)
{ {
// Just defaulting it if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
var twoFactorProviderType = TwoFactorProviderType.Authenticator;
if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out twoFactorProviderType))
{ {
await BuildTwoFactorResultAsync(user, twoFactorOrganization, context); await BuildTwoFactorResultAsync(user, twoFactorOrganization, context);
return; return;
@ -162,7 +155,6 @@ public abstract class BaseRequestValidator<T> where T : class
twoFactorToken = null; twoFactorToken = null;
} }
// Force legacy users to the web for migration // Force legacy users to the web for migration
if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers)) if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers))
{ {
@ -176,7 +168,7 @@ public abstract class BaseRequestValidator<T> where T : class
// Returns true if can finish validation process // Returns true if can finish validation process
if (await IsValidAuthTypeAsync(user, request.GrantType)) if (await IsValidAuthTypeAsync(user, request.GrantType))
{ {
var device = await SaveDeviceAsync(user, request); var device = await _deviceValidator.SaveDeviceAsync(user, request);
if (device == null) if (device == null)
{ {
await BuildErrorResultAsync("No device information provided.", false, context, user); 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; 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, private async Task<bool> VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType type,
string token) 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) private async Task ResetFailedAuthDetailsAsync(User user)
{ {
// Early escape if db hit not necessary // Early escape if db hit not necessary

View File

@ -29,8 +29,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
public CustomTokenRequestValidator( public CustomTokenRequestValidator(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceRepository deviceRepository, IDeviceValidator deviceValidator,
IDeviceService deviceService,
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
@ -48,7 +47,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory, IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService, IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, deviceRepository, deviceService, userService, eventService, : base(userManager, userService, eventService, deviceValidator,
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, applicationCacheService, mailService, logger, currentContext, globalSettings,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository,
@ -83,11 +82,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{ {
context.Result.CustomResponse = new Dictionary<string, object> { { "encrypted_payload", payload } }; context.Result.CustomResponse = new Dictionary<string, object> { { "encrypted_payload", payload } };
} }
return; return;
} }
await ValidateAsync(context, context.Result.ValidatedRequest, await ValidateAsync(context, context.Result.ValidatedRequest,
new CustomValidatorRequestContext { KnownDevice = true }); new CustomValidatorRequestContext { KnownDevice = true });
} }
@ -103,7 +99,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{ {
validatorContext.User = await _userManager.FindByEmailAsync(email); validatorContext.User = await _userManager.FindByEmailAsync(email);
} }
return validatorContext.User != null; return validatorContext.User != null;
} }
@ -121,7 +116,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.ValidatedRequest.ClientClaims.Add(claim); context.Result.ValidatedRequest.ClientClaims.Add(claim);
} }
} }
if (context.Result.CustomResponse == null || user.MasterPassword != null) if (context.Result.CustomResponse == null || user.MasterPassword != null)
{ {
return Task.CompletedTask; return Task.CompletedTask;
@ -138,7 +132,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.CustomResponse["ApiUseKeyConnector"] = true; context.Result.CustomResponse["ApiUseKeyConnector"] = true;
context.Result.CustomResponse["ResetMasterPassword"] = false; context.Result.CustomResponse["ResetMasterPassword"] = false;
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -150,13 +143,11 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
if (userDecryptionOptions is { KeyConnectorOption: { } }) if (userDecryptionOptions is { KeyConnectorOption: { } })
{ {
context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl; context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl;
context.Result.CustomResponse["ResetMasterPassword"] = false; context.Result.CustomResponse["ResetMasterPassword"] = false;
} }
return Task.CompletedTask; 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 ICurrentContext _currentContext;
private readonly ICaptchaValidationService _captchaValidationService; private readonly ICaptchaValidationService _captchaValidationService;
private readonly IAuthRequestRepository _authRequestRepository; private readonly IAuthRequestRepository _authRequestRepository;
private readonly IDeviceValidator _deviceValidator;
public ResourceOwnerPasswordValidator( public ResourceOwnerPasswordValidator(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService, ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -48,7 +48,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, deviceRepository, deviceService, userService, eventService, : base(userManager, userService, eventService, deviceValidator,
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService,
tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder)
@ -57,6 +57,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
_currentContext = currentContext; _currentContext = currentContext;
_captchaValidationService = captchaValidationService; _captchaValidationService = captchaValidationService;
_authRequestRepository = authRequestRepository; _authRequestRepository = authRequestRepository;
_deviceValidator = deviceValidator;
} }
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
@ -72,7 +73,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
var validatorContext = new CustomValidatorRequestContext var validatorContext = new CustomValidatorRequestContext
{ {
User = user, User = user,
KnownDevice = await KnownDeviceAsync(user, context.Request) KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request),
}; };
string bypassToken = null; string bypassToken = null;
if (!validatorContext.KnownDevice && if (!validatorContext.KnownDevice &&

View File

@ -27,13 +27,13 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector; private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand; private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
private readonly IDeviceValidator _deviceValidator;
public WebAuthnGrantValidator( public WebAuthnGrantValidator(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService, ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -52,13 +52,14 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
) )
: base(userManager, deviceRepository, deviceService, userService, eventService, : base(userManager, userService, eventService, deviceValidator,
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, applicationCacheService, mailService, logger, currentContext, globalSettings,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder)
{ {
_assertionOptionsDataProtector = assertionOptionsDataProtector; _assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
_deviceValidator = deviceValidator;
} }
string IExtensionGrantValidator.GrantType => "webauthn"; string IExtensionGrantValidator.GrantType => "webauthn";
@ -87,7 +88,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
var validatorContext = new CustomValidatorRequestContext var validatorContext = new CustomValidatorRequestContext
{ {
User = user, User = user,
KnownDevice = await KnownDeviceAsync(user, context.Request) KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request)
}; };
UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential); UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);

View File

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

View File

@ -4,11 +4,15 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Identity.IdentityServer;
using Bit.Identity.Models.Request.Accounts; using Bit.Identity.Models.Request.Accounts;
using Bit.IntegrationTestCommon.Factories; using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers; using Bit.Test.Common.Helpers;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation; namespace Bit.Identity.IntegrationTest.RequestValidation;
@ -21,6 +25,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
private readonly IdentityApplicationFactory _factory; private readonly IdentityApplicationFactory _factory;
private readonly UserManager<User> _userManager; private readonly UserManager<User> _userManager;
private readonly IAuthRequestRepository _authRequestRepository; private readonly IAuthRequestRepository _authRequestRepository;
private readonly IDeviceService _deviceService;
public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory) public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory)
{ {
@ -28,13 +33,13 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
_userManager = _factory.GetService<UserManager<User>>(); _userManager = _factory.GetService<UserManager<User>>();
_authRequestRepository = _factory.GetService<IAuthRequestRepository>(); _authRequestRepository = _factory.GetService<IAuthRequestRepository>();
_deviceService = _factory.GetService<IDeviceService>();
} }
[Fact] [Fact]
public async Task ValidateAsync_Success() public async Task ValidateAsync_Success()
{ {
// Arrange // Arrange
await EnsureUserCreatedAsync(); await EnsureUserCreatedAsync();
// Act // Act
@ -53,7 +58,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
[Fact] [Fact]
public async Task ValidateAsync_AuthEmailHeaderInvalid_InvalidGrantResponse() public async Task ValidateAsync_AuthEmailHeaderInvalid_InvalidGrantResponse()
{ {
// Arrange // Arrange
await EnsureUserCreatedAsync(); await EnsureUserCreatedAsync();
// Act // Act
@ -88,12 +93,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
} }
/// <summary> /// <summary>
/// I would have liked to spy into the IUserService but by spying into the IUserService it /// 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 /// 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 /// 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 /// UserManager<User> has access to (This is an assumption made from observing the behavior while
/// writing theses tests. I could be wrong. /// 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 /// For the time being, verifying that the user is not null confirms that the failure is due to
/// a bad password. /// a bad password.
/// </summary> /// </summary>
@ -102,10 +107,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_BadPassword_Failure(string badPassword) public async Task ValidateAsync_BadPassword_Failure(string badPassword)
{ {
// Arrange // Arrange
await EnsureUserCreatedAsync(); await EnsureUserCreatedAsync();
// Verify the User is not null to ensure the failure is due to bad password // Verify the User is not null to ensure the failure is due to bad password
Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername));
// Act // Act
var context = await _factory.Server.PostAsync("/connect/token", var context = await _factory.Server.PostAsync("/connect/token",
@ -113,8 +119,6 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
context => context.SetAuthEmail(DefaultUsername)); context => context.SetAuthEmail(DefaultUsername));
// Assert // Assert
Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername));
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context); var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement; var root = body.RootElement;
@ -200,8 +204,8 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
// Assert // Assert
/* /*
An improvement on the current failure flow would be to document which part of 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. the flow failed since all of the failures are basically the same.
This doesn't build confidence in the tests. 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); 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) private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory = null)
{ {
factory ??= _factory; factory ??= _factory;

View File

@ -29,10 +29,9 @@ namespace Bit.Identity.Test.IdentityServer;
public class BaseRequestValidatorTests public class BaseRequestValidatorTests
{ {
private UserManager<User> _userManager; private UserManager<User> _userManager;
private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider;
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService; private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
@ -53,10 +52,9 @@ public class BaseRequestValidatorTests
public BaseRequestValidatorTests() public BaseRequestValidatorTests()
{ {
_deviceRepository = Substitute.For<IDeviceRepository>();
_deviceService = Substitute.For<IDeviceService>();
_userService = Substitute.For<IUserService>(); _userService = Substitute.For<IUserService>();
_eventService = Substitute.For<IEventService>(); _eventService = Substitute.For<IEventService>();
_deviceValidator = Substitute.For<IDeviceValidator>();
_organizationDuoWebTokenProvider = Substitute.For<IOrganizationDuoWebTokenProvider>(); _organizationDuoWebTokenProvider = Substitute.For<IOrganizationDuoWebTokenProvider>();
_duoWebV4SDKService = Substitute.For<ITemporaryDuoWebV4SDKService>(); _duoWebV4SDKService = Substitute.For<ITemporaryDuoWebV4SDKService>();
_organizationRepository = Substitute.For<IOrganizationRepository>(); _organizationRepository = Substitute.For<IOrganizationRepository>();
@ -76,10 +74,9 @@ public class BaseRequestValidatorTests
_sut = new BaseRequestValidatorTestWrapper( _sut = new BaseRequestValidatorTestWrapper(
_userManager, _userManager,
_deviceRepository,
_deviceService,
_userService, _userService,
_eventService, _eventService,
_deviceValidator,
_organizationDuoWebTokenProvider, _organizationDuoWebTokenProvider,
_duoWebV4SDKService, _duoWebV4SDKService,
_organizationRepository, _organizationRepository,
@ -228,7 +225,8 @@ public class BaseRequestValidatorTests
public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed( public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext, CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult) GrantValidationResult grantResult,
Device device)
{ {
// Arrange // Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
@ -240,18 +238,13 @@ public class BaseRequestValidatorTests
_globalSettings.DisableEmailNewDevice = false; _globalSettings.DisableEmailNewDevice = false;
context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device 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 // Act
await _sut.ValidateAsync(context); await _sut.ValidateAsync(context);
// Assert // Assert
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
context.CustomValidatorRequestContext.User.Email, "Android", Arg.Any<DateTime>(), Arg.Any<string>()
);
Assert.False(context.GrantResult.IsError); Assert.False(context.GrantResult.IsError);
} }
@ -262,7 +255,8 @@ public class BaseRequestValidatorTests
public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed( public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext, CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult) GrantValidationResult grantResult,
Device device)
{ {
// Arrange // Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
@ -274,13 +268,9 @@ public class BaseRequestValidatorTests
_globalSettings.DisableEmailNewDevice = false; _globalSettings.DisableEmailNewDevice = false;
context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device 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>()) _deviceValidator.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(new Device() { Identifier = "DeviceIdentifier" }); .Returns(device);
// Act // Act
await _sut.ValidateAsync(context); 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 bool isValid { get; set; }
public BaseRequestValidatorTestWrapper( public BaseRequestValidatorTestWrapper(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService, ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -72,10 +71,9 @@ IBaseRequestValidatorTestWrapper
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) :
base( base(
userManager, userManager,
deviceRepository,
deviceService,
userService, userService,
eventService, eventService,
deviceValidator,
organizationDuoWebTokenProvider, organizationDuoWebTokenProvider,
duoWebV4SDKService, duoWebV4SDKService,
organizationRepository, organizationRepository,

View File

@ -145,7 +145,10 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
// Email Verification // Email Verification
{ "globalSettings:enableEmailVerification", "true" }, { "globalSettings:enableEmailVerification", "true" },
{ "globalSettings:disableUserRegistration", "false" }, { "globalSettings:disableUserRegistration", "false" },
{ "globalSettings:launchDarkly:flagValues:email-verification", "true" } { "globalSettings:launchDarkly:flagValues:email-verification", "true" },
// New Device Verification
{ "globalSettings:disableEmailNewDevice", "false" },
}); });
}); });