diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs
index a7e7254b808f..21c821f7aad1 100644
--- a/src/Identity/IdentityServer/BaseRequestValidator.cs
+++ b/src/Identity/IdentityServer/BaseRequestValidator.cs
@@ -101,7 +101,7 @@ public BaseRequestValidator(
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext)
{
- var isBot = (validatorContext.CaptchaResponse?.IsBot ?? false);
+ var isBot = validatorContext.CaptchaResponse?.IsBot ?? false;
if (isBot)
{
_logger.LogInformation(Constants.BypassFiltersEventId,
@@ -621,6 +621,13 @@ private async Task UpdateFailedAuthDetailsAsync(User user, bool twoFactorInvalid
}
}
+ ///
+ /// checks to see if a user is trying to log into a new device
+ /// and has reached the maximum number of failed login attempts.
+ ///
+ /// boolean
+ /// current user
+ ///
private bool ValidateFailedAuthEmailConditions(bool unknownDevice, User user)
{
var failedLoginCeiling = _globalSettings.Captcha.MaximumFailedLoginAttempts;
diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs
new file mode 100644
index 000000000000..fac271b14a0a
--- /dev/null
+++ b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs
@@ -0,0 +1,272 @@
+using System.Text.Json;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Bit.Identity.Models.Request.Accounts;
+using Bit.IntegrationTestCommon.Factories;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Bit.Test.Common.Helpers;
+using Microsoft.AspNetCore.Identity;
+using Xunit;
+
+namespace Bit.Identity.IntegrationTest.RequestValidation;
+
+public class ResourceOwnerPasswordValidatorTests : IClassFixture
+{
+ private const string DefaultPassword = "master_password_hash";
+ private const string DefaultUsername = "test@email.qa";
+ private const string DefaultDeviceIdentifier = "test_identifier";
+ private readonly IdentityApplicationFactory _factory;
+ private readonly UserManager _userManager;
+ private readonly IAuthRequestRepository _authRequestRepository;
+
+ public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory)
+ {
+ _factory = factory;
+
+ _userManager = _factory.GetService>();
+ _authRequestRepository = _factory.GetService();
+
+ }
+
+ [Fact]
+ public async Task ValidateAsync_Success()
+ {
+ // Arrange
+ await EnsureUserCreatedAsync();
+
+ // 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 token = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString();
+ Assert.NotNull(token);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_AuthEmailHeaderInvalid_InvalidGrantResponse()
+ {
+ // Arrange
+ await EnsureUserCreatedAsync();
+
+ // Act
+ var context = await _factory.Server.PostAsync(
+ "/connect/token",
+ GetFormUrlEncodedContent()
+ );
+
+ // Assert
+ var body = await AssertHelper.AssertResponseTypeIs(context);
+ var root = body.RootElement;
+
+ var error = AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String).GetString();
+ Assert.Equal("Auth-Email header invalid.", error);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_UserNull_Failure(string username)
+ {
+ // Act
+ var context = await _factory.Server.PostAsync("/connect/token",
+ GetFormUrlEncodedContent(username: username),
+ context => context.SetAuthEmail(username));
+
+ // 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("Username or password is incorrect. Try again.", errorMessage);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// For the time being, verifying that the user is not null confirms that the failure is due to
+ /// a bad password.
+ ///
+ /// random password
+ ///
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_BadPassword_Failure(string badPassword)
+ {
+ // Arrange
+ await EnsureUserCreatedAsync();
+
+ // Verify the User is not null to ensure the failure is due to bad password
+
+ // Act
+ var context = await _factory.Server.PostAsync("/connect/token",
+ GetFormUrlEncodedContent(password: badPassword),
+ context => context.SetAuthEmail(DefaultUsername));
+
+ // Assert
+ Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername));
+
+ 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("Username or password is incorrect. Try again.", errorMessage);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_ValidateContextAsync_AuthRequest_NotNull_AgeLessThanOneHour_Success()
+ {
+ // Arrange
+ // Ensure User
+ await EnsureUserCreatedAsync();
+ var user = await _userManager.FindByEmailAsync(DefaultUsername);
+ Assert.NotNull(user);
+
+ // Connect Request to User and set CreationDate
+ var authRequest = CreateAuthRequest(
+ user.Id,
+ AuthRequestType.AuthenticateAndUnlock,
+ DateTime.UtcNow.AddMinutes(-30)
+ );
+ await _authRequestRepository.CreateAsync(authRequest);
+
+ var expectedAuthRequest = await _authRequestRepository.GetManyByUserIdAsync(user.Id);
+ Assert.NotEmpty(expectedAuthRequest);
+
+ // Act
+ var context = await _factory.Server.PostAsync("/connect/token",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ { "scope", "api offline_access" },
+ { "client_id", "web" },
+ { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
+ { "deviceIdentifier", DefaultDeviceIdentifier },
+ { "deviceName", "firefox" },
+ { "grant_type", "password" },
+ { "username", DefaultUsername },
+ { "password", DefaultPassword },
+ { "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
+ }), context => context.SetAuthEmail(DefaultUsername));
+
+ // Assert
+ var body = await AssertHelper.AssertResponseTypeIs(context);
+ var root = body.RootElement;
+
+ var token = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString();
+ Assert.NotNull(token);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_ValidateContextAsync_AuthRequest_NotNull_AgeGreaterThanOneHour_Failure()
+ {
+ // Arrange
+ // Ensure User
+ await EnsureUserCreatedAsync(_factory);
+ var user = await _userManager.FindByEmailAsync(DefaultUsername);
+ Assert.NotNull(user);
+
+ // Create AuthRequest
+ var authRequest = CreateAuthRequest(
+ user.Id,
+ AuthRequestType.AuthenticateAndUnlock,
+ DateTime.UtcNow.AddMinutes(-61)
+ );
+
+ // Act
+ var context = await _factory.Server.PostAsync("/connect/token",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ { "scope", "api offline_access" },
+ { "client_id", "web" },
+ { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
+ { "deviceIdentifier", DefaultDeviceIdentifier },
+ { "deviceName", "firefox" },
+ { "grant_type", "password" },
+ { "username", DefaultUsername },
+ { "password", DefaultPassword },
+ { "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
+ }), context => context.SetAuthEmail(DefaultUsername));
+
+ // 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.
+ This doesn't build confidence in the tests.
+ */
+
+ 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("Username or password is incorrect. Try again.", errorMessage);
+ }
+
+ private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory = null)
+ {
+ factory ??= _factory;
+ // No need to create more users than we need
+ if (await _userManager.FindByEmailAsync(DefaultUsername) == null)
+ {
+ // Register user
+ await factory.RegisterAsync(new RegisterRequestModel
+ {
+ Email = DefaultUsername,
+ MasterPasswordHash = DefaultPassword
+ });
+ }
+ }
+
+ private FormUrlEncodedContent GetFormUrlEncodedContent(
+ string deviceId = null, string username = null, string password = null)
+ {
+ return new FormUrlEncodedContent(new Dictionary
+ {
+ { "scope", "api offline_access" },
+ { "client_id", "web" },
+ { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
+ { "deviceIdentifier", deviceId ?? DefaultDeviceIdentifier },
+ { "deviceName", "firefox" },
+ { "grant_type", "password" },
+ { "username", username ?? DefaultUsername },
+ { "password", password ?? DefaultPassword },
+ });
+ }
+
+ private static string DeviceTypeAsString(DeviceType deviceType)
+ {
+ return ((int)deviceType).ToString();
+ }
+
+ private static AuthRequest CreateAuthRequest(
+ Guid userId,
+ AuthRequestType authRequestType,
+ DateTime creationDate,
+ bool? approved = null,
+ DateTime? responseDate = null)
+ {
+ return new AuthRequest
+ {
+ UserId = userId,
+ Type = authRequestType,
+ Approved = approved,
+ RequestDeviceIdentifier = DefaultDeviceIdentifier,
+ RequestIpAddress = "1.1.1.1",
+ AccessCode = DefaultPassword,
+ PublicKey = "test_public_key",
+ CreationDate = creationDate,
+ ResponseDate = responseDate,
+ };
+ }
+}
diff --git a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs
new file mode 100644
index 000000000000..5ee3bda95611
--- /dev/null
+++ b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs
@@ -0,0 +1,31 @@
+using System.Reflection;
+using AutoFixture;
+using AutoFixture.Xunit2;
+using Duende.IdentityServer.Validation;
+
+namespace Bit.Identity.Test.AutoFixture;
+
+internal class ValidatedTokenRequestCustomization : ICustomization
+{
+ public ValidatedTokenRequestCustomization()
+ { }
+
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer => composer
+ .With(o => o.RefreshToken, () => null)
+ .With(o => o.ClientClaims, [])
+ .With(o => o.Options, new Duende.IdentityServer.Configuration.IdentityServerOptions()));
+ }
+}
+
+public class ValidatedTokenRequestAttribute : CustomizeAttribute
+{
+ public ValidatedTokenRequestAttribute()
+ { }
+
+ public override ICustomization GetCustomization(ParameterInfo parameter)
+ {
+ return new ValidatedTokenRequestCustomization();
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
new file mode 100644
index 000000000000..c1d34e1b047e
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
@@ -0,0 +1,400 @@
+using Bit.Core;
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Services;
+using Bit.Core.Auth.Identity;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Models.Api;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Settings;
+using Bit.Core.Tokens;
+using Bit.Identity.IdentityServer;
+using Bit.Identity.Test.Wrappers;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Duende.IdentityServer.Validation;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Xunit;
+using AuthFixtures = Bit.Identity.Test.AutoFixture;
+
+
+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 IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider;
+ private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService;
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly IApplicationCacheService _applicationCacheService;
+ private readonly IMailService _mailService;
+ private readonly ILogger _logger;
+ private readonly ICurrentContext _currentContext;
+ private readonly GlobalSettings _globalSettings;
+ private readonly IUserRepository _userRepository;
+ private readonly IPolicyService _policyService;
+ private readonly IDataProtectorTokenFactory _tokenDataFactory;
+ private readonly IFeatureService _featureService;
+ private readonly ISsoConfigRepository _ssoConfigRepository;
+ private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder;
+
+ private readonly BaseRequestValidatorTestWrapper _sut;
+
+ public BaseRequestValidatorTests()
+ {
+ _deviceRepository = Substitute.For();
+ _deviceService = Substitute.For();
+ _userService = Substitute.For();
+ _eventService = Substitute.For();
+ _organizationDuoWebTokenProvider = Substitute.For();
+ _duoWebV4SDKService = Substitute.For();
+ _organizationRepository = Substitute.For();
+ _organizationUserRepository = Substitute.For();
+ _applicationCacheService = Substitute.For();
+ _mailService = Substitute.For();
+ _logger = Substitute.For>();
+ _currentContext = Substitute.For();
+ _globalSettings = Substitute.For();
+ _userRepository = Substitute.For();
+ _policyService = Substitute.For();
+ _tokenDataFactory = Substitute.For>();
+ _featureService = Substitute.For();
+ _ssoConfigRepository = Substitute.For();
+ _userDecryptionOptionsBuilder = Substitute.For();
+ _userManager = SubstituteUserManager();
+
+ _sut = new BaseRequestValidatorTestWrapper(
+ _userManager,
+ _deviceRepository,
+ _deviceService,
+ _userService,
+ _eventService,
+ _organizationDuoWebTokenProvider,
+ _duoWebV4SDKService,
+ _organizationRepository,
+ _organizationUserRepository,
+ _applicationCacheService,
+ _mailService,
+ _logger,
+ _currentContext,
+ _globalSettings,
+ _userRepository,
+ _policyService,
+ _tokenDataFactory,
+ _featureService,
+ _ssoConfigRepository,
+ _userDecryptionOptionsBuilder);
+ }
+
+ /* Logic path
+ ValidateAsync -> _Logger.LogInformation
+ |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
+ |-> SetErrorResult
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_IsBot_UserNotNull_ShouldBuildErrorResult_ShouldLogFailedLoginEvent(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = true;
+ _sut.isValid = true;
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+
+ // Assert
+ await _eventService.Received(1)
+ .LogUserEventAsync(context.CustomValidatorRequestContext.User.Id,
+ Core.Enums.EventType.User_FailedLogIn);
+ Assert.True(context.GrantResult.IsError);
+ Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
+ }
+
+ /* Logic path
+ ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
+ |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
+ (self hosted) |-> _logger.LogWarning()
+ |-> SetErrorResult
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ _globalSettings.Captcha.Returns(new GlobalSettings.CaptchaSettings());
+ _globalSettings.SelfHosted = true;
+ _sut.isValid = false;
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ _logger.Received(1).LogWarning(Constants.BypassFiltersEventId, "Failed login attempt. ");
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+ Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
+ }
+
+ /* Logic path
+ ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
+ |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
+ |-> SetErrorResult
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_ContextNotValid_MaxAttemptLogin_ShouldSendEmail(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ // This needs to be n-1 of the max failed login attempts
+ context.CustomValidatorRequestContext.User.FailedLoginCount = 2;
+ context.CustomValidatorRequestContext.KnownDevice = false;
+
+ _globalSettings.Captcha.Returns(
+ new GlobalSettings.CaptchaSettings
+ {
+ MaximumFailedLoginAttempts = 3
+ });
+ _sut.isValid = false;
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ await _mailService.Received(1)
+ .SendFailedLoginAttemptsEmailAsync(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.True(context.GrantResult.IsError);
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+ Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
+ }
+
+
+ /* Logic path
+ ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildErrorResult
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_AuthCodeGrantType_DeviceNull_ShouldError(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ _sut.isValid = true;
+
+ context.ValidatedTokenRequest.GrantType = "authorization_code";
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+
+ // Assert
+ Assert.True(context.GrantResult.IsError);
+ Assert.Equal("No device information provided.", errorResponse.Message);
+ }
+
+ /* Logic path
+ ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildSuccessResultAsync
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ _sut.isValid = true;
+
+ context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
+ _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";
+
+ // 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);
+ }
+
+ /* Logic path
+ ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildSuccessResultAsync
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ _sut.isValid = true;
+
+ context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
+ _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" });
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ await _eventService.LogUserEventAsync(
+ context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
+ await _userRepository.Received(1).ReplaceAsync(Arg.Any());
+
+ Assert.False(context.GrantResult.IsError);
+ }
+
+ /* Logic path
+ ValidateAsync -> IsLegacyUser -> BuildErrorResultAsync
+ */
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_InvalidAuthType_ShouldSetSsoResult(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.ValidatedTokenRequest.Raw["DeviceIdentifier"] = "DeviceIdentifier";
+ context.ValidatedTokenRequest.Raw["DevicePushToken"] = "DevicePushToken";
+ context.ValidatedTokenRequest.Raw["DeviceName"] = "DeviceName";
+ context.ValidatedTokenRequest.Raw["DeviceType"] = "Android"; // This needs to be an actual Type
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ _sut.isValid = true;
+
+ context.ValidatedTokenRequest.GrantType = "";
+
+ _policyService.AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(true));
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ Assert.True(context.GrantResult.IsError);
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+ Assert.Equal("SSO authentication is required.", errorResponse.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ var user = context.CustomValidatorRequestContext.User;
+ user.Key = null;
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ context.ValidatedTokenRequest.ClientId = "Not Web";
+ _sut.isValid = true;
+ _featureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers).Returns(true);
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ Assert.True(context.GrantResult.IsError);
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+ Assert.Equal($"Encryption key migration is required. Please log in to the web vault at {_globalSettings.BaseServiceUri.VaultWithHash}"
+ , errorResponse.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task RequiresTwoFactorAsync_ClientCredentialsGrantType_ShouldReturnFalse(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+
+ context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
+ context.ValidatedTokenRequest.GrantType = "client_credentials";
+
+ // Act
+ var result = await _sut.TestRequiresTwoFactorAsync(
+ context.CustomValidatorRequestContext.User,
+ context.ValidatedTokenRequest);
+
+ // Assert
+ Assert.False(result.Item1);
+ Assert.Null(result.Item2);
+ }
+
+ private BaseRequestValidationContextFake CreateContext(
+ ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ return new BaseRequestValidationContextFake(
+ tokenRequest,
+ requestContext,
+ grantResult
+ );
+ }
+
+ private UserManager SubstituteUserManager()
+ {
+ return new UserManager(Substitute.For>(),
+ Substitute.For>(),
+ Substitute.For>(),
+ Enumerable.Empty>(),
+ Enumerable.Empty>(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For>>());
+ }
+}
diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
new file mode 100644
index 000000000000..e525d0de764c
--- /dev/null
+++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
@@ -0,0 +1,152 @@
+using System.Security.Claims;
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Services;
+using Bit.Core.Auth.Identity;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Settings;
+using Bit.Core.Tokens;
+using Bit.Identity.IdentityServer;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Validation;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+
+namespace Bit.Identity.Test.Wrappers;
+
+public class BaseRequestValidationContextFake
+{
+ public ValidatedTokenRequest ValidatedTokenRequest;
+ public CustomValidatorRequestContext CustomValidatorRequestContext;
+ public GrantValidationResult GrantResult;
+
+ public BaseRequestValidationContextFake(
+ ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext customValidatorRequestContext,
+ GrantValidationResult grantResult)
+ {
+ ValidatedTokenRequest = tokenRequest;
+ CustomValidatorRequestContext = customValidatorRequestContext;
+ GrantResult = grantResult;
+ }
+}
+
+interface IBaseRequestValidatorTestWrapper
+{
+ Task ValidateAsync(BaseRequestValidationContextFake context);
+}
+
+public class BaseRequestValidatorTestWrapper : BaseRequestValidator,
+IBaseRequestValidatorTestWrapper
+{
+
+ /*
+ * Some of the logic trees call `ValidateContextAsync`. Since this is a test wrapper, we set the return value
+ * of ValidateContextAsync() to whatever we need for the specific test case.
+ */
+ public bool isValid { get; set; }
+ public BaseRequestValidatorTestWrapper(
+ UserManager userManager,
+ IDeviceRepository deviceRepository,
+ IDeviceService deviceService,
+ IUserService userService,
+ IEventService eventService,
+ IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
+ ITemporaryDuoWebV4SDKService duoWebV4SDKService,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IApplicationCacheService applicationCacheService,
+ IMailService mailService,
+ ILogger logger,
+ ICurrentContext currentContext,
+ GlobalSettings globalSettings,
+ IUserRepository userRepository,
+ IPolicyService policyService,
+ IDataProtectorTokenFactory tokenDataFactory,
+ IFeatureService featureService,
+ ISsoConfigRepository ssoConfigRepository,
+ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) :
+ base(
+ userManager,
+ deviceRepository,
+ deviceService,
+ userService,
+ eventService,
+ organizationDuoWebTokenProvider,
+ duoWebV4SDKService,
+ organizationRepository,
+ organizationUserRepository,
+ applicationCacheService,
+ mailService,
+ logger,
+ currentContext,
+ globalSettings,
+ userRepository,
+ policyService,
+ tokenDataFactory,
+ featureService,
+ ssoConfigRepository,
+ userDecryptionOptionsBuilder)
+ {
+ }
+
+ public async Task ValidateAsync(
+ BaseRequestValidationContextFake context)
+ {
+ await ValidateAsync(context, context.ValidatedTokenRequest, context.CustomValidatorRequestContext);
+ }
+
+ public async Task> TestRequiresTwoFactorAsync(
+ User user,
+ ValidatedTokenRequest context)
+ {
+ return await RequiresTwoFactorAsync(user, context);
+ }
+
+ protected override ClaimsPrincipal GetSubject(
+ BaseRequestValidationContextFake context)
+ {
+ return context.ValidatedTokenRequest.Subject ?? new ClaimsPrincipal();
+ }
+
+ protected override void SetErrorResult(
+ BaseRequestValidationContextFake context,
+ Dictionary customResponse)
+ {
+ context.GrantResult = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
+ }
+
+ protected override void SetSsoResult(
+ BaseRequestValidationContextFake context,
+ Dictionary customResponse)
+ {
+ context.GrantResult = new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant, "Sso authentication required.", customResponse);
+ }
+
+ protected override Task SetSuccessResult(
+ BaseRequestValidationContextFake context,
+ User user,
+ List claims,
+ Dictionary customResponse)
+ {
+ context.GrantResult = new GrantValidationResult(customResponse: customResponse);
+ return Task.CompletedTask;
+ }
+
+ protected override void SetTwoFactorResult(
+ BaseRequestValidationContextFake context,
+ Dictionary customResponse)
+ { }
+
+ protected override Task ValidateContextAsync(
+ BaseRequestValidationContextFake context,
+ CustomValidatorRequestContext validatorContext)
+ {
+ return Task.FromResult(isValid);
+ }
+}