1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 08:02:49 -05:00

[PM-20348] Add pending auth request endpoint (#5957)

* Feat(pm-20348): 
  * Add migration scripts for Read Pending Auth Requests by UserId stored procedure and new `view` for pending AuthRequest. 
  * View only returns the most recent pending authRequest, or none at all if the most recent is answered.
  * Implement stored procedure in AuthRequestRepository for both Dapper and Entity Framework.
  * Update AuthRequestController to query the new View to get a user's most recent pending auth requests response includes the requesting deviceId.

* Doc: 
  * Move summary xml comments to interface.
  * Added comments for the AuthRequestService.

* Test: 
  * Added testing for AuthRequestsController.
  * Added testing for repositories. 
  * Added integration tests for multiple auth requests but only returning the most recent.
This commit is contained in:
Ike
2025-06-30 13:17:51 -04:00
committed by GitHub
parent 899ff1b660
commit 20bf1455cf
14 changed files with 752 additions and 50 deletions

View File

@ -25,9 +25,4 @@
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Auth\" />
<Folder Include="Auth\Controllers\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,258 @@
using System.Security.Claims;
using Bit.Api.Auth.Controllers;
using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Services;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Auth.Controllers;
[ControllerCustomize(typeof(AuthRequestsController))]
[SutProviderCustomize]
public class AuthRequestsControllerTests
{
const string _testGlobalSettingsBaseUri = "https://vault.test.dev";
[Theory, BitAutoData]
public async Task Get_ReturnsExpectedResult(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequest authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
sutProvider.GetDependency<IAuthRequestRepository>()
.GetManyByUserIdAsync(user.Id)
.Returns([authRequest]);
// Act
var result = await sutProvider.Sut.Get();
// Assert
Assert.NotNull(result);
var expectedCount = 1;
Assert.Equal(result.Data.Count(), expectedCount);
Assert.IsType<ListResponseModel<AuthRequestResponseModel>>(result);
}
[Theory, BitAutoData]
public async Task GetById_ThrowsNotFoundException(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequest authRequest)
{
// Arrange
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
sutProvider.GetDependency<IAuthRequestService>()
.GetAuthRequestAsync(authRequest.Id, user.Id)
.Returns((AuthRequest)null);
// Act
// Assert
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.Get(authRequest.Id));
}
[Theory, BitAutoData]
public async Task GetById_ReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequest authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
sutProvider.GetDependency<IAuthRequestService>()
.GetAuthRequestAsync(authRequest.Id, user.Id)
.Returns(authRequest);
// Act
var result = await sutProvider.Sut.Get(authRequest.Id);
// Assert
Assert.NotNull(result);
Assert.IsType<AuthRequestResponseModel>(result);
}
[Theory, BitAutoData]
public async Task GetPending_ReturnsExpectedResult(
SutProvider<AuthRequestsController> sutProvider,
User user,
PendingAuthRequestDetails authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
sutProvider.GetDependency<IAuthRequestRepository>()
.GetManyPendingAuthRequestByUserId(user.Id)
.Returns([authRequest]);
// Act
var result = await sutProvider.Sut.GetPendingAuthRequestsAsync();
// Assert
Assert.NotNull(result);
var expectedCount = 1;
Assert.Equal(result.Data.Count(), expectedCount);
Assert.IsType<ListResponseModel<PendingAuthRequestResponseModel>>(result);
}
[Theory, BitAutoData]
public async Task GetResponseById_ThrowsNotFoundException(
SutProvider<AuthRequestsController> sutProvider,
AuthRequest authRequest)
{
// Arrange
sutProvider.GetDependency<IAuthRequestService>()
.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode)
.Returns((AuthRequest)null);
// Act
// Assert
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode));
}
[Theory, BitAutoData]
public async Task GetResponseById_ReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
AuthRequest authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
sutProvider.GetDependency<IAuthRequestService>()
.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode)
.Returns(authRequest);
// Act
var result = await sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode);
// Assert
Assert.NotNull(result);
Assert.IsType<AuthRequestResponseModel>(result);
}
[Theory, BitAutoData]
public async Task Post_AdminApprovalRequest_ThrowsBadRequestException(
SutProvider<AuthRequestsController> sutProvider,
AuthRequestCreateRequestModel authRequest)
{
// Arrange
authRequest.Type = AuthRequestType.AdminApproval;
// Act
// Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.Post(authRequest));
var expectedMessage = "You must be authenticated to create a request of that type.";
Assert.Equal(exception.Message, expectedMessage);
}
[Theory, BitAutoData]
public async Task Post_ReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
AuthRequestCreateRequestModel requestModel,
AuthRequest authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
requestModel.Type = AuthRequestType.AuthenticateAndUnlock;
sutProvider.GetDependency<IAuthRequestService>()
.CreateAuthRequestAsync(requestModel)
.Returns(authRequest);
// Act
var result = await sutProvider.Sut.Post(requestModel);
// Assert
Assert.NotNull(result);
Assert.IsType<AuthRequestResponseModel>(result);
}
[Theory, BitAutoData]
public async Task PostAdminRequest_ReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
AuthRequestCreateRequestModel requestModel,
AuthRequest authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
requestModel.Type = AuthRequestType.AuthenticateAndUnlock;
sutProvider.GetDependency<IAuthRequestService>()
.CreateAuthRequestAsync(requestModel)
.Returns(authRequest);
// Act
var result = await sutProvider.Sut.PostAdminRequest(requestModel);
// Assert
Assert.NotNull(result);
Assert.IsType<AuthRequestResponseModel>(result);
}
[Theory, BitAutoData]
public async Task Put_ReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequestUpdateRequestModel requestModel,
AuthRequest authRequest)
{
// Arrange
SetBaseServiceUri(sutProvider);
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
sutProvider.GetDependency<IAuthRequestService>()
.UpdateAuthRequestAsync(authRequest.Id, user.Id, requestModel)
.Returns(authRequest);
// Act
var result = await sutProvider.Sut
.Put(authRequest.Id, requestModel);
// Assert
Assert.NotNull(result);
Assert.IsType<AuthRequestResponseModel>(result);
}
private void SetBaseServiceUri(SutProvider<AuthRequestsController> sutProvider)
{
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri
.Vault
.Returns(_testGlobalSettingsBaseUri);
}
}

View File

@ -66,10 +66,8 @@ public class AuthRequestRepositoryTests
Assert.NotNull(await authRequestRepository.GetByIdAsync(notExpiredAdminApprovalRequest.Id));
Assert.NotNull(await authRequestRepository.GetByIdAsync(notExpiredApprovedAdminApprovalRequest.Id));
// Ensure the repository responds with the amount of items it deleted and it deleted the right amount.
// NOTE: On local development this might fail on it's first run because the developer could have expired AuthRequests
// on their machine but aren't running the job that would delete them. The second run of this test should succeed.
Assert.Equal(4, numberOfDeleted);
// Ensure the repository responds with the amount of items it deleted and it deleted the right amount, which could include other auth requests from other tests so we take the minimum known acceptable amount.
Assert.True(numberOfDeleted >= 4);
}
[DatabaseTheory, DatabaseData]
@ -182,7 +180,157 @@ public class AuthRequestRepositoryTests
Assert.Null(uncreatedAuthRequest);
}
private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null)
/// <summary>
/// Test to determine that when no valid authRequest exists in the database the return value is null.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyPendingAuthRequestByUserId_AuthRequestsInvalid_ReturnsEmptyEnumerable_Success(
IAuthRequestRepository authRequestRepository,
IUserRepository userRepository)
{
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
List<AuthRequest> authRequests = [];
// A user auth request type that has passed its expiration time, should not be returned.
var authRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
CreateExpiredDate(_userRequestExpiration));
authRequest.RequestDeviceIdentifier = "auth_request_expired";
authRequests.Add(await authRequestRepository.CreateAsync(authRequest));
// A valid time AuthRequest but for pending we do not fetch admin auth requests
authRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AdminApproval,
DateTime.UtcNow.AddMinutes(-1));
authRequest.RequestDeviceIdentifier = "admin_auth_request";
authRequests.Add(await authRequestRepository.CreateAsync(authRequest));
// A valid time AuthRequest but the request has been approved/rejected, so it should not be returned.
authRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-1),
false);
authRequest.RequestDeviceIdentifier = "approved_auth_request";
authRequests.Add(await authRequestRepository.CreateAsync(authRequest));
var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id);
Assert.NotNull(result);
Assert.Empty(result);
// Verify that there are authRequests associated with the user.
Assert.NotEmpty(await authRequestRepository.GetManyByUserIdAsync(user.Id));
await CleanupTestAsync(authRequests, authRequestRepository);
}
/// <summary>
/// Test to determine that when multiple valid authRequest exist for a device only the soonest one is returned.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyPendingAuthRequestByUserId_MultipleRequestForSingleDevice_ReturnsMostRecent(
IAuthRequestRepository authRequestRepository,
IUserRepository userRepository)
{
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var oneMinuteOldAuthRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-1));
oneMinuteOldAuthRequest = await authRequestRepository.CreateAsync(oneMinuteOldAuthRequest);
var fiveMinuteOldAuthRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-5));
fiveMinuteOldAuthRequest = await authRequestRepository.CreateAsync(fiveMinuteOldAuthRequest);
var tenMinuteOldAuthRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-10));
tenMinuteOldAuthRequest = await authRequestRepository.CreateAsync(tenMinuteOldAuthRequest);
var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id);
Assert.NotNull(result);
// since we group by device there should only be a single return since the device Id is the same
Assert.Single(result);
var resultAuthRequest = result.First();
Assert.Equal(oneMinuteOldAuthRequest.Id, resultAuthRequest.Id);
List<AuthRequest> authRequests = [oneMinuteOldAuthRequest, fiveMinuteOldAuthRequest, tenMinuteOldAuthRequest];
await CleanupTestAsync(authRequests, authRequestRepository);
}
/// <summary>
/// Test to determine that when multiple authRequests exist for a device if the most recent is approved then
/// there should be no return.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyPendingAuthRequestByUserId_MultipleRequestForSingleDevice_MostRecentIsApproved_ReturnsEmpty(
IAuthRequestRepository authRequestRepository,
IUserRepository userRepository)
{
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
// approved auth request
var oneMinuteOldAuthRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-1),
false);
oneMinuteOldAuthRequest = await authRequestRepository.CreateAsync(oneMinuteOldAuthRequest);
var fiveMinuteOldAuthRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-5));
fiveMinuteOldAuthRequest = await authRequestRepository.CreateAsync(fiveMinuteOldAuthRequest);
var tenMinuteOldAuthRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-10));
tenMinuteOldAuthRequest = await authRequestRepository.CreateAsync(tenMinuteOldAuthRequest);
var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id);
Assert.NotNull(result);
// result should be empty since the most recent request was addressed
Assert.Empty(result);
List<AuthRequest> authRequests = [oneMinuteOldAuthRequest, fiveMinuteOldAuthRequest, tenMinuteOldAuthRequest];
await CleanupTestAsync(authRequests, authRequestRepository);
}
private static AuthRequest CreateAuthRequest(
Guid userId,
AuthRequestType authRequestType,
DateTime creationDate,
bool? approved = null,
DateTime? responseDate = null)
{
return new AuthRequest
{
@ -203,4 +351,20 @@ public class AuthRequestRepositoryTests
var exp = expirationPeriod + TimeSpan.FromMinutes(1);
return DateTime.UtcNow.Add(exp.Negate());
}
/// <summary>
/// Cleans up the test data created by the test methods. This supports the DeleteExpiredAsync Test.
/// </summary>
/// <param name="authRequests">Created Auth Requests</param>
/// <param name="authRequestRepository">repository context for the current test</param>
/// <returns>void</returns>
private static async Task CleanupTestAsync(
IEnumerable<AuthRequest> authRequests,
IAuthRequestRepository authRequestRepository)
{
foreach (var authRequest in authRequests)
{
await authRequestRepository.DeleteAsync(authRequest);
}
}
}