Skip to content

Commit

Permalink
[PM-6664] Base Request Validator Unit Tests and Resource Owner integr…
Browse files Browse the repository at this point in the history
…ation Tests (#4582)

* intial commit

* Some UnitTests for the VerifyAsync flows

* WIP org two factor

* removed useless tests

* added ResourceOwnerValidation integration tests

* fixing formatting

* addressing comments

* removed comment
  • Loading branch information
ike-kottlowski authored Sep 5, 2024
1 parent 64a7cba commit fa5d671
Show file tree
Hide file tree
Showing 5 changed files with 863 additions and 1 deletion.
9 changes: 8 additions & 1 deletion src/Identity/IdentityServer/BaseRequestValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -621,6 +621,13 @@ private async Task UpdateFailedAuthDetailsAsync(User user, bool twoFactorInvalid
}
}

/// <summary>
/// checks to see if a user is trying to log into a new device
/// and has reached the maximum number of failed login attempts.
/// </summary>
/// <param name="unknownDevice">boolean</param>
/// <param name="user">current user</param>
/// <returns></returns>
private bool ValidateFailedAuthEmailConditions(bool unknownDevice, User user)
{
var failedLoginCeiling = _globalSettings.Captcha.MaximumFailedLoginAttempts;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IdentityApplicationFactory>
{
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<User> _userManager;
private readonly IAuthRequestRepository _authRequestRepository;

public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory)
{
_factory = factory;

_userManager = _factory.GetService<UserManager<User>>();
_authRequestRepository = _factory.GetService<IAuthRequestRepository>();

}

[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<JsonDocument>(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<JsonDocument>(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<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("Username or password is incorrect. Try again.", errorMessage);
}

/// <summary>
/// I would have liked to spy into the IUserService but by spying into the IUserService it
/// creates a Singleton that is not available to the UserManager<User> thus causing the
/// RegisterAsync() to create a the user in a different UserStore than the one the
/// UserManager<User> has access to. This is an assumption made from observing the behavior while
/// writing theses tests. I could be wrong.
///
/// For the time being, verifying that the user is not null confirms that the failure is due to
/// a bad password.
/// </summary>
/// <param name="badPassword">random password</param>
/// <returns></returns>
[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<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("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<string, string>
{
{ "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<JsonDocument>(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<string, string>
{
{ "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<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("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<string, string>
{
{ "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,
};
}
}
31 changes: 31 additions & 0 deletions test/Identity.Test/AutoFixture/RequestValidationFixtures.cs
Original file line number Diff line number Diff line change
@@ -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<ValidatedTokenRequest>(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();
}
}
Loading

0 comments on commit fa5d671

Please sign in to comment.