1
0
mirror of https://github.com/bitwarden/server.git synced 2025-01-29 23:01:46 +01:00

Auth/pm 2996/add auth request data to devices response model (#5152)

fix(auth): [PM-2996] Add Pending Auth Request Data to Devices Response
- New stored procedure to fetch the appropriate data.
- Updated devices controller to respond with the new data.
- Tests written at the controller and repository level.
Resolves PM-2996
This commit is contained in:
Patrick-Pimentel-Bitwarden 2025-01-07 15:52:53 -05:00 committed by GitHub
parent 5ae232e336
commit cc96e35072
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 620 additions and 30 deletions

View File

@ -237,7 +237,7 @@ jobs:
run: | run: |
if grep -q "<Operations>" "report.xml"; then if grep -q "<Operations>" "report.xml"; then
echo echo
echo "Migrations are out of sync with sqlproj!" echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project."
exit 1 exit 1
else else
echo "Report looks good" echo "Report looks good"

View File

@ -6,7 +6,6 @@ using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -70,11 +69,17 @@ public class DevicesController : Controller
} }
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<DeviceResponseModel>> Get() public async Task<ListResponseModel<DeviceAuthRequestResponseModel>> Get()
{ {
ICollection<Device> devices = await _deviceRepository.GetManyByUserIdAsync(_userService.GetProperUserId(User).Value); var devicesWithPendingAuthData = await _deviceRepository.GetManyByUserIdWithDeviceAuth(_userService.GetProperUserId(User).Value);
var responses = devices.Select(d => new DeviceResponseModel(d));
return new ListResponseModel<DeviceResponseModel>(responses); // Convert from DeviceAuthDetails to DeviceAuthRequestResponseModel
var deviceAuthRequestResponseList = devicesWithPendingAuthData
.Select(DeviceAuthRequestResponseModel.From)
.ToList();
var response = new ListResponseModel<DeviceAuthRequestResponseModel>(deviceAuthRequestResponseList);
return response;
} }
[HttpPost("")] [HttpPost("")]

View File

@ -1,5 +1,12 @@
namespace Bit.Core.Auth.Enums; namespace Bit.Core.Auth.Enums;
/**
* The type of auth request.
*
* Note:
* Used by the Device_ReadActiveWithPendingAuthRequestsByUserId.sql stored procedure.
* If the enum changes be aware of this reference.
*/
public enum AuthRequestType : byte public enum AuthRequestType : byte
{ {
AuthenticateAndUnlock = 0, AuthenticateAndUnlock = 0,

View File

@ -0,0 +1,51 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
namespace Bit.Core.Auth.Models.Api.Response;
public class DeviceAuthRequestResponseModel : ResponseModel
{
public DeviceAuthRequestResponseModel()
: base("device") { }
public static DeviceAuthRequestResponseModel From(DeviceAuthDetails deviceAuthDetails)
{
var converted = new DeviceAuthRequestResponseModel
{
Id = deviceAuthDetails.Id,
Name = deviceAuthDetails.Name,
Type = deviceAuthDetails.Type,
Identifier = deviceAuthDetails.Identifier,
CreationDate = deviceAuthDetails.CreationDate,
IsTrusted = deviceAuthDetails.IsTrusted()
};
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
{
converted.DevicePendingAuthRequest = new PendingAuthRequest
{
Id = (Guid)deviceAuthDetails.AuthRequestId,
CreationDate = (DateTime)deviceAuthDetails.AuthRequestCreatedAt
};
}
return converted;
}
public Guid Id { get; set; }
public string Name { get; set; }
public DeviceType Type { get; set; }
public string Identifier { get; set; }
public DateTime CreationDate { get; set; }
public bool IsTrusted { get; set; }
public PendingAuthRequest DevicePendingAuthRequest { get; set; }
public class PendingAuthRequest
{
public Guid Id { get; set; }
public DateTime CreationDate { get; set; }
}
}

View File

@ -0,0 +1,81 @@
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Auth.Models.Data;
public class DeviceAuthDetails : Device
{
public bool IsTrusted { get; set; }
public Guid? AuthRequestId { get; set; }
public DateTime? AuthRequestCreatedAt { get; set; }
/**
* Constructor for EF response.
*/
public DeviceAuthDetails(
Device device,
Guid? authRequestId,
DateTime? authRequestCreationDate)
{
if (device == null)
{
throw new ArgumentNullException(nameof(device));
}
Id = device.Id;
Name = device.Name;
Type = device.Type;
Identifier = device.Identifier;
CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted();
AuthRequestId = authRequestId;
AuthRequestCreatedAt = authRequestCreationDate;
}
/**
* Constructor for dapper response.
* Note: if the authRequestId or authRequestCreationDate is null it comes back as
* an empty guid and a min value for datetime. That could change if the stored
* procedure runs on a different kind of db.
*/
public DeviceAuthDetails(
Guid id,
Guid userId,
string name,
short type,
string identifier,
string pushToken,
DateTime creationDate,
DateTime revisionDate,
string encryptedUserKey,
string encryptedPublicKey,
string encryptedPrivateKey,
bool active,
Guid authRequestId,
DateTime authRequestCreationDate)
{
Id = id;
Name = name;
Type = (DeviceType)type;
Identifier = identifier;
CreationDate = creationDate;
IsTrusted = new Device
{
Id = id,
UserId = userId,
Name = name,
Type = (DeviceType)type,
Identifier = identifier,
PushToken = pushToken,
RevisionDate = revisionDate,
EncryptedUserKey = encryptedUserKey,
EncryptedPublicKey = encryptedPublicKey,
EncryptedPrivateKey = encryptedPrivateKey,
Active = active
}.IsTrusted();
AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null;
AuthRequestCreatedAt =
authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null;
}
}

View File

@ -1,5 +1,4 @@
 using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Entities;
namespace Bit.Core.Auth.Models.Data; namespace Bit.Core.Auth.Models.Data;

View File

@ -23,7 +23,6 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
public class RegisterUserCommand : IRegisterUserCommand public class RegisterUserCommand : IRegisterUserCommand
{ {
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities; using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
#nullable enable #nullable enable
@ -10,5 +11,9 @@ public interface IDeviceRepository : IRepository<Device, Guid>
Task<Device?> GetByIdentifierAsync(string identifier); Task<Device?> GetByIdentifierAsync(string identifier);
Task<Device?> GetByIdentifierAsync(string identifier, Guid userId); Task<Device?> GetByIdentifierAsync(string identifier, Guid userId);
Task<ICollection<Device>> GetManyByUserIdAsync(Guid userId); Task<ICollection<Device>> GetManyByUserIdAsync(Guid userId);
// DeviceAuthDetails is passed back to decouple the response model from the
// repository in case more fields are ever added to the details response for
// other requests.
Task<ICollection<DeviceAuthDetails>> GetManyByUserIdWithDeviceAuth(Guid userId);
Task ClearPushTokenAsync(Guid id); Task ClearPushTokenAsync(Guid id);
} }

View File

@ -24,5 +24,7 @@ public interface IGlobalSettings
IPasswordlessAuthSettings PasswordlessAuth { get; set; } IPasswordlessAuthSettings PasswordlessAuth { get; set; }
IDomainVerificationSettings DomainVerification { get; set; } IDomainVerificationSettings DomainVerification { get; set; }
ILaunchDarklySettings LaunchDarkly { get; set; } ILaunchDarklySettings LaunchDarkly { get; set; }
string DatabaseProvider { get; set; }
GlobalSettings.SqlSettings SqlServer { get; set; }
string DevelopmentDirectory { get; set; } string DevelopmentDirectory { get; set; }
} }

View File

@ -1,4 +1,5 @@
using System.Data; using System.Data;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
@ -11,9 +12,13 @@ namespace Bit.Infrastructure.Dapper.Repositories;
public class DeviceRepository : Repository<Device, Guid>, IDeviceRepository public class DeviceRepository : Repository<Device, Guid>, IDeviceRepository
{ {
private readonly IGlobalSettings _globalSettings;
public DeviceRepository(GlobalSettings globalSettings) public DeviceRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ } {
_globalSettings = globalSettings;
}
public DeviceRepository(string connectionString, string readOnlyConnectionString) public DeviceRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString) : base(connectionString, readOnlyConnectionString)
@ -76,6 +81,24 @@ public class DeviceRepository : Repository<Device, Guid>, IDeviceRepository
} }
} }
public async Task<ICollection<DeviceAuthDetails>> GetManyByUserIdWithDeviceAuth(Guid userId)
{
var expirationMinutes = _globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes;
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<DeviceAuthDetails>(
$"[{Schema}].[{Table}_ReadActiveWithPendingAuthRequestsByUserId]",
new
{
UserId = userId,
ExpirationMinutes = expirationMinutes
},
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task ClearPushTokenAsync(Guid id) public async Task ClearPushTokenAsync(Guid id)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))

View File

@ -51,7 +51,7 @@ public abstract class Repository<T, TId> : BaseRepository, IRepository<T, TId>
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
parameters.AddDynamicParams(obj); parameters.AddDynamicParams(obj);
parameters.Add("Id", obj.Id, direction: ParameterDirection.InputOutput); parameters.Add("Id", obj.Id, direction: ParameterDirection.InputOutput);
var results = await connection.ExecuteAsync( await connection.ExecuteAsync(
$"[{Schema}].[{Table}_Create]", $"[{Schema}].[{Table}_Create]",
parameters, parameters,
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
@ -64,7 +64,7 @@ public abstract class Repository<T, TId> : BaseRepository, IRepository<T, TId>
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))
{ {
var results = await connection.ExecuteAsync( await connection.ExecuteAsync(
$"[{Schema}].[{Table}_Update]", $"[{Schema}].[{Table}_Update]",
obj, obj,
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);

View File

@ -0,0 +1,38 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Infrastructure.EntityFramework.Repositories;
namespace Bit.Infrastructure.EntityFramework.Auth.Repositories.Queries;
public class DeviceWithPendingAuthByUserIdQuery
{
public IQueryable<DeviceAuthDetails> GetQuery(
DatabaseContext dbContext,
Guid userId,
int expirationMinutes)
{
var devicesWithAuthQuery = (
from device in dbContext.Devices
where device.UserId == userId && device.Active
select new
{
device,
authRequest =
(
from authRequest in dbContext.AuthRequests
where authRequest.RequestDeviceIdentifier == device.Identifier
where authRequest.Type == AuthRequestType.AuthenticateAndUnlock || authRequest.Type == AuthRequestType.Unlock
where authRequest.Approved == null
where authRequest.UserId == userId
where authRequest.CreationDate.AddMinutes(expirationMinutes) > DateTime.UtcNow
orderby authRequest.CreationDate descending
select authRequest
).First()
}).Select(deviceWithAuthRequest => new DeviceAuthDetails(
deviceWithAuthRequest.device,
deviceWithAuthRequest.authRequest.Id,
deviceWithAuthRequest.authRequest.CreationDate));
return devicesWithAuthQuery;
}
}

View File

@ -1,5 +1,8 @@
using AutoMapper; using AutoMapper;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Infrastructure.EntityFramework.Auth.Repositories.Queries;
using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -10,9 +13,17 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
public class DeviceRepository : Repository<Core.Entities.Device, Device, Guid>, IDeviceRepository public class DeviceRepository : Repository<Core.Entities.Device, Device, Guid>, IDeviceRepository
{ {
public DeviceRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) private readonly IGlobalSettings _globalSettings;
public DeviceRepository(
IServiceScopeFactory serviceScopeFactory,
IMapper mapper,
IGlobalSettings globalSettings
)
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Devices) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Devices)
{ } {
_globalSettings = globalSettings;
}
public async Task ClearPushTokenAsync(Guid id) public async Task ClearPushTokenAsync(Guid id)
{ {
@ -69,4 +80,15 @@ public class DeviceRepository : Repository<Core.Entities.Device, Device, Guid>,
return Mapper.Map<List<Core.Entities.Device>>(devices); return Mapper.Map<List<Core.Entities.Device>>(devices);
} }
} }
public async Task<ICollection<DeviceAuthDetails>> GetManyByUserIdWithDeviceAuth(Guid userId)
{
var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes;
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = new DeviceWithPendingAuthByUserIdQuery();
return await query.GetQuery(dbContext, userId, expirationMinutes).ToListAsync();
}
}
} }

View File

@ -0,0 +1,27 @@
CREATE PROCEDURE [dbo].[Device_ReadActiveWithPendingAuthRequestsByUserId]
@UserId UNIQUEIDENTIFIER,
@ExpirationMinutes INT
AS
BEGIN
SET NOCOUNT ON;
SELECT
D.*,
AR.Id as AuthRequestId,
AR.CreationDate as AuthRequestCreationDate
FROM dbo.DeviceView D
LEFT JOIN (
SELECT TOP 1 -- Take only the top record sorted by auth request creation date
Id,
CreationDate,
RequestDeviceIdentifier
FROM dbo.AuthRequestView
WHERE Type IN (0, 1) -- Include only AuthenticateAndUnlock and Unlock types, excluding Admin Approval (type 2)
AND CreationDate >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired
AND Approved IS NULL -- Include only requests that haven't been acknowledged or approved
ORDER BY CreationDate DESC
) AR ON D.Identifier = AR.RequestDeviceIdentifier
WHERE
D.UserId = @UserId
AND D.Active = 1; -- Include only active devices
END;

View File

@ -0,0 +1,88 @@
using Bit.Api.Controllers;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Data;
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 Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Auth.Controllers;
public class DevicesControllerTest
{
private readonly IDeviceRepository _deviceRepositoryMock;
private readonly IDeviceService _deviceServiceMock;
private readonly IUserService _userServiceMock;
private readonly IUserRepository _userRepositoryMock;
private readonly ICurrentContext _currentContextMock;
private readonly IGlobalSettings _globalSettingsMock;
private readonly ILogger<DevicesController> _loggerMock;
private readonly DevicesController _sut;
public DevicesControllerTest()
{
_deviceRepositoryMock = Substitute.For<IDeviceRepository>();
_deviceServiceMock = Substitute.For<IDeviceService>();
_userServiceMock = Substitute.For<IUserService>();
_userRepositoryMock = Substitute.For<IUserRepository>();
_currentContextMock = Substitute.For<ICurrentContext>();
_loggerMock = Substitute.For<ILogger<DevicesController>>();
_sut = new DevicesController(
_deviceRepositoryMock,
_deviceServiceMock,
_userServiceMock,
_userRepositoryMock,
_currentContextMock,
_loggerMock);
}
[Fact]
public async Task Get_ReturnsExpectedResult()
{
// Arrange
var userId = Guid.Parse("AD89E6F8-4E84-4CFE-A978-256CC0DBF974");
var authDateTimeResponse = new DateTime(2024, 12, 9, 12, 0, 0);
var devicesWithPendingAuthData = new List<DeviceAuthDetails>
{
new (
new Device
{
Id = Guid.Parse("B3136B10-7818-444F-B05B-4D7A9B8C48BF"),
UserId = userId,
Name = "chrome",
Type = DeviceType.ChromeBrowser,
Identifier = Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString()
},
Guid.Parse("E09D6943-D574-49E5-AC85-C3F12B4E019E"),
authDateTimeResponse)
};
_userServiceMock.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>()).Returns(userId);
_deviceRepositoryMock.GetManyByUserIdWithDeviceAuth(userId).Returns(devicesWithPendingAuthData);
// Act
var result = await _sut.Get();
// Assert
Assert.NotNull(result);
Assert.IsType<ListResponseModel<DeviceAuthRequestResponseModel>>(result);
}
[Fact]
public async Task Get_ThrowsException_WhenUserIdIsInvalid()
{
// Arrange
_userServiceMock.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>()).Returns((Guid?)null);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.Get());
}
}

View File

@ -2,7 +2,9 @@
using AutoFixture.Kernel; using AutoFixture.Kernel;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Test.AutoFixture.UserFixtures; using Bit.Core.Test.AutoFixture.UserFixtures;
using Bit.Infrastructure.EFIntegration.Test.Auth.AutoFixture;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture.Relays; using Bit.Infrastructure.EFIntegration.Test.AutoFixture.Relays;
using Bit.Infrastructure.EntityFramework.Auth.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@ -39,8 +41,10 @@ internal class EfDevice : ICustomization
fixture.Customizations.Add(new GlobalSettingsBuilder()); fixture.Customizations.Add(new GlobalSettingsBuilder());
fixture.Customizations.Add(new DeviceBuilder()); fixture.Customizations.Add(new DeviceBuilder());
fixture.Customizations.Add(new UserBuilder()); fixture.Customizations.Add(new UserBuilder());
fixture.Customizations.Add(new AuthRequestBuilder());
fixture.Customizations.Add(new EfRepositoryListBuilder<DeviceRepository>()); fixture.Customizations.Add(new EfRepositoryListBuilder<DeviceRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<UserRepository>()); fixture.Customizations.Add(new EfRepositoryListBuilder<UserRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<AuthRequestRepository>());
} }
} }

View File

@ -11,9 +11,13 @@ namespace Bit.Infrastructure.EFIntegration.Test.Repositories;
public class DeviceRepositoryTests public class DeviceRepositoryTests
{ {
[CiSkippedTheory, EfDeviceAutoData] [CiSkippedTheory, EfDeviceAutoData]
public async Task CreateAsync_Works_DataMatches(Device device, User user, public async Task CreateAsync_Works_DataMatches(
DeviceCompare equalityComparer, List<EfRepo.DeviceRepository> suts, Device device,
List<EfRepo.UserRepository> efUserRepos, SqlRepo.DeviceRepository sqlDeviceRepo, User user,
DeviceCompare equalityComparer,
List<EfRepo.DeviceRepository> suts,
List<EfRepo.UserRepository> efUserRepos,
SqlRepo.DeviceRepository sqlDeviceRepo,
SqlRepo.UserRepository sqlUserRepo) SqlRepo.UserRepository sqlUserRepo)
{ {
var savedDevices = new List<Device>(); var savedDevices = new List<Device>();
@ -40,7 +44,6 @@ public class DeviceRepositoryTests
savedDevices.Add(savedSqlDevice); savedDevices.Add(savedSqlDevice);
var distinctItems = savedDevices.Distinct(equalityComparer); var distinctItems = savedDevices.Distinct(equalityComparer);
Assert.True(!distinctItems.Skip(1).Any()); Assert.False(distinctItems.Skip(1).Any());
} }
} }

View File

@ -8,9 +8,9 @@ namespace Bit.Infrastructure.IntegrationTest.Auth.Repositories;
public class AuthRequestRepositoryTests public class AuthRequestRepositoryTests
{ {
private readonly static TimeSpan _userRequestExpiration = TimeSpan.FromMinutes(15); private static readonly TimeSpan _userRequestExpiration = TimeSpan.FromMinutes(15);
private readonly static TimeSpan _adminRequestExpiration = TimeSpan.FromDays(6); private static readonly TimeSpan _adminRequestExpiration = TimeSpan.FromDays(6);
private readonly static TimeSpan _afterAdminApprovalExpiration = TimeSpan.FromHours(12); private static readonly TimeSpan _afterAdminApprovalExpiration = TimeSpan.FromHours(12);
[DatabaseTheory, DatabaseData] [DatabaseTheory, DatabaseData]
public async Task DeleteExpiredAsync_Works( public async Task DeleteExpiredAsync_Works(
@ -25,11 +25,11 @@ public class AuthRequestRepositoryTests
SecurityStamp = "stamp", SecurityStamp = "stamp",
}); });
// A user auth request type that has passed it's expiration time, should be deleted. // A user auth request type that has passed its expiration time, should be deleted.
var userExpiredAuthRequest = await authRequestRepository.CreateAsync( var userExpiredAuthRequest = await authRequestRepository.CreateAsync(
CreateAuthRequest(user.Id, AuthRequestType.AuthenticateAndUnlock, CreateExpiredDate(_userRequestExpiration))); CreateAuthRequest(user.Id, AuthRequestType.AuthenticateAndUnlock, CreateExpiredDate(_userRequestExpiration)));
// An AdminApproval request that hasn't had any action taken on it and has passed it's expiration time, should be deleted. // An AdminApproval request that hasn't had any action taken on it and has passed its expiration time, should be deleted.
var adminApprovalExpiredAuthRequest = await authRequestRepository.CreateAsync( var adminApprovalExpiredAuthRequest = await authRequestRepository.CreateAsync(
CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, CreateExpiredDate(_adminRequestExpiration))); CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, CreateExpiredDate(_adminRequestExpiration)));
@ -37,7 +37,7 @@ public class AuthRequestRepositoryTests
var adminApprovedExpiredAuthRequest = await authRequestRepository.CreateAsync( var adminApprovedExpiredAuthRequest = await authRequestRepository.CreateAsync(
CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddDays(-6), true, CreateExpiredDate(_afterAdminApprovalExpiration))); CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddDays(-6), true, CreateExpiredDate(_afterAdminApprovalExpiration)));
// An AdminApproval request that was rejected within it's allowed lifetime but has no gone past it's expiration time, should be deleted. // An AdminApproval request that was rejected within its allowed lifetime but has not gone past its expiration time, should be deleted.
var adminRejectedExpiredAuthRequest = await authRequestRepository.CreateAsync( var adminRejectedExpiredAuthRequest = await authRequestRepository.CreateAsync(
CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, CreateExpiredDate(_adminRequestExpiration), false, DateTime.UtcNow.AddHours(-1))); CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, CreateExpiredDate(_adminRequestExpiration), false, DateTime.UtcNow.AddHours(-1)));
@ -45,7 +45,7 @@ public class AuthRequestRepositoryTests
var notExpiredUserAuthRequest = await authRequestRepository.CreateAsync( var notExpiredUserAuthRequest = await authRequestRepository.CreateAsync(
CreateAuthRequest(user.Id, AuthRequestType.Unlock, DateTime.UtcNow.AddMinutes(-1))); CreateAuthRequest(user.Id, AuthRequestType.Unlock, DateTime.UtcNow.AddMinutes(-1)));
// An AdminApproval AuthRequest that was create 6 days 23 hours 59 minutes 59 seconds ago which is right on the edge of still being valid // An AdminApproval AuthRequest that was created 6 days 23 hours 59 minutes 59 seconds ago which is right on the edge of still being valid
var notExpiredAdminApprovalRequest = await authRequestRepository.CreateAsync( var notExpiredAdminApprovalRequest = await authRequestRepository.CreateAsync(
CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.Add(new TimeSpan(days: 6, hours: 23, minutes: 59, seconds: 59)))); CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.Add(new TimeSpan(days: 6, hours: 23, minutes: 59, seconds: 59))));

View File

@ -0,0 +1,191 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.Auth.Repositories;
public class DeviceRepositoryTests
{
[DatabaseTheory]
[DatabaseData]
public async Task GetManyByUserIdWithDeviceAuth_Works_ReturnsExpectedResults(
IDeviceRepository sutRepository,
IUserRepository userRepository,
IAuthRequestRepository authRequestRepository)
{
// Arrange
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var device = await sutRepository.CreateAsync(new Device
{
Active = true,
Name = "chrome-test",
UserId = user.Id,
Type = DeviceType.ChromeBrowser,
Identifier = Guid.NewGuid().ToString(),
});
var staleAuthRequest = await authRequestRepository.CreateAsync(new AuthRequest
{
ResponseDeviceId = null,
Approved = null,
Type = AuthRequestType.AuthenticateAndUnlock,
OrganizationId = null,
UserId = user.Id,
RequestIpAddress = ":1",
RequestDeviceIdentifier = device.Identifier,
AccessCode = "AccessCode_1234",
PublicKey = "PublicKey_1234"
});
staleAuthRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
await authRequestRepository.ReplaceAsync(staleAuthRequest);
var freshAuthRequest = await authRequestRepository.CreateAsync(new AuthRequest
{
ResponseDeviceId = null,
Approved = null,
Type = AuthRequestType.AuthenticateAndUnlock,
OrganizationId = null,
UserId = user.Id,
RequestIpAddress = ":1",
RequestDeviceIdentifier = device.Identifier,
AccessCode = "AccessCode_1234",
PublicKey = "PublicKey_1234",
Key = "Key_1234",
MasterPasswordHash = "MasterPasswordHash_1234"
});
// Act
var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id);
// Assert
Assert.NotNull(response.First().AuthRequestId);
Assert.NotNull(response.First().AuthRequestCreatedAt);
Assert.Equal(response.First().AuthRequestId, freshAuthRequest.Id);
}
[DatabaseTheory]
[DatabaseData]
public async Task GetManyByUserIdWithDeviceAuth_WorksWithNoAuthRequestAndMultipleDevices_ReturnsExpectedResults(
IDeviceRepository sutRepository,
IUserRepository userRepository)
{
// Arrange
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
await sutRepository.CreateAsync(new Device
{
Active = true,
Name = "chrome-test",
UserId = user.Id,
Type = DeviceType.ChromeBrowser,
Identifier = Guid.NewGuid().ToString(),
});
await sutRepository.CreateAsync(new Device
{
Active = true,
Name = "macos-test",
UserId = user.Id,
Type = DeviceType.MacOsDesktop,
Identifier = Guid.NewGuid().ToString(),
});
// Act
var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id);
// Assert
Assert.NotNull(response.First());
Assert.Null(response.First().AuthRequestId);
Assert.True(response.Count == 2);
}
[DatabaseTheory]
[DatabaseData]
public async Task GetManyByUserIdWithDeviceAuth_FailsToRespondWithAnyAuthData_ReturnsExpectedResults(
IDeviceRepository sutRepository,
IUserRepository userRepository,
IAuthRequestRepository authRequestRepository)
{
var casesThatCauseNoAuthDataInResponse = new[]
{
new
{
authRequestType = AuthRequestType.AdminApproval, // Device typing is wrong
authRequestApproved = (bool?)null,
expirey = DateTime.UtcNow.AddMinutes(0),
},
new
{
authRequestType = AuthRequestType.AuthenticateAndUnlock,
authRequestApproved = (bool?)true, // Auth request is already approved
expirey = DateTime.UtcNow.AddMinutes(0),
},
new
{
authRequestType = AuthRequestType.AuthenticateAndUnlock,
authRequestApproved = (bool?)null,
expirey = DateTime.UtcNow.AddMinutes(-30), // Past the point of expiring
}
};
foreach (var testCase in casesThatCauseNoAuthDataInResponse)
{
// Arrange
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var device = await sutRepository.CreateAsync(new Device
{
Active = true,
Name = "chrome-test",
UserId = user.Id,
Type = DeviceType.ChromeBrowser,
Identifier = Guid.NewGuid().ToString(),
});
var authRequest = await authRequestRepository.CreateAsync(new AuthRequest
{
ResponseDeviceId = null,
Approved = testCase.authRequestApproved,
Type = testCase.authRequestType,
OrganizationId = null,
UserId = user.Id,
RequestIpAddress = ":1",
RequestDeviceIdentifier = device.Identifier,
AccessCode = "AccessCode_1234",
PublicKey = "PublicKey_1234"
});
authRequest.CreationDate = testCase.expirey;
await authRequestRepository.ReplaceAsync(authRequest);
// Act
var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id);
// Assert
Assert.Null(response.First().AuthRequestId);
Assert.Null(response.First().AuthRequestCreatedAt);
}
}
}

View File

@ -41,6 +41,9 @@ public class DatabaseDataAttribute : DataAttribute
protected virtual IEnumerable<IServiceProvider> GetDatabaseProviders(IConfiguration config) protected virtual IEnumerable<IServiceProvider> GetDatabaseProviders(IConfiguration config)
{ {
// This is for the device repository integration testing.
var userRequestExpiration = 15;
var configureLogging = (ILoggingBuilder builder) => var configureLogging = (ILoggingBuilder builder) =>
{ {
if (!config.GetValue<bool>("Quiet")) if (!config.GetValue<bool>("Quiet"))
@ -67,11 +70,15 @@ public class DatabaseDataAttribute : DataAttribute
{ {
ConnectionString = database.ConnectionString, ConnectionString = database.ConnectionString,
}, },
PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings
{
UserRequestExpiration = TimeSpan.FromMinutes(userRequestExpiration),
}
}; };
dapperSqlServerCollection.AddSingleton(globalSettings); dapperSqlServerCollection.AddSingleton(globalSettings);
dapperSqlServerCollection.AddSingleton<IGlobalSettings>(globalSettings); dapperSqlServerCollection.AddSingleton<IGlobalSettings>(globalSettings);
dapperSqlServerCollection.AddSingleton(database); dapperSqlServerCollection.AddSingleton(database);
dapperSqlServerCollection.AddDistributedSqlServerCache((o) => dapperSqlServerCollection.AddDistributedSqlServerCache(o =>
{ {
o.ConnectionString = database.ConnectionString; o.ConnectionString = database.ConnectionString;
o.SchemaName = "dbo"; o.SchemaName = "dbo";
@ -91,6 +98,17 @@ public class DatabaseDataAttribute : DataAttribute
AddCommonServices(efCollection, configureLogging); AddCommonServices(efCollection, configureLogging);
efCollection.SetupEntityFramework(database.ConnectionString, database.Type); efCollection.SetupEntityFramework(database.ConnectionString, database.Type);
efCollection.AddPasswordManagerEFRepositories(SelfHosted); efCollection.AddPasswordManagerEFRepositories(SelfHosted);
var globalSettings = new GlobalSettings
{
PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings
{
UserRequestExpiration = TimeSpan.FromMinutes(userRequestExpiration),
}
};
efCollection.AddSingleton(globalSettings);
efCollection.AddSingleton<IGlobalSettings>(globalSettings);
efCollection.AddSingleton(database); efCollection.AddSingleton(database);
efCollection.AddSingleton<IDistributedCache, EntityFrameworkCache>(); efCollection.AddSingleton<IDistributedCache, EntityFrameworkCache>();
@ -117,7 +135,7 @@ public class DatabaseDataAttribute : DataAttribute
private void AddSqlMigrationTester(IServiceCollection services, string connectionString, string migrationName) private void AddSqlMigrationTester(IServiceCollection services, string connectionString, string migrationName)
{ {
services.AddSingleton<IMigrationTesterService, SqlMigrationTesterService>(sp => new SqlMigrationTesterService(connectionString, migrationName)); services.AddSingleton<IMigrationTesterService, SqlMigrationTesterService>(_ => new SqlMigrationTesterService(connectionString, migrationName));
} }
private void AddEfMigrationTester(IServiceCollection services, SupportedDatabaseProviders databaseType, string migrationName) private void AddEfMigrationTester(IServiceCollection services, SupportedDatabaseProviders databaseType, string migrationName)

View File

@ -0,0 +1,27 @@
CREATE OR ALTER PROCEDURE [dbo].[Device_ReadActiveWithPendingAuthRequestsByUserId]
@UserId UNIQUEIDENTIFIER,
@ExpirationMinutes INT
AS
BEGIN
SET NOCOUNT ON;
SELECT
D.*,
AR.Id as AuthRequestId,
AR.CreationDate as AuthRequestCreationDate
FROM dbo.DeviceView D
LEFT JOIN (
SELECT TOP 1 -- Take only the top record sorted by auth request creation date
Id,
CreationDate,
RequestDeviceIdentifier
FROM dbo.AuthRequestView
WHERE Type IN (0, 1) -- Include only AuthenticateAndUnlock and Unlock types, excluding Admin Approval (type 2)
AND CreationDate >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired
AND Approved IS NULL -- Include only requests that haven't been acknowledged or approved
ORDER BY CreationDate DESC
) AR ON D.Identifier = AR.RequestDeviceIdentifier
WHERE
D.UserId = @UserId
AND D.Active = 1; -- Include only active devices
END;