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); + } +}