1
0
mirror of https://github.com/bitwarden/server.git synced 2025-01-27 22:41:22 +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

@ -107,7 +107,7 @@ jobs:
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Migrate MariaDB
working-directory: "util/MySqlMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
@ -237,7 +237,7 @@ jobs:
run: |
if grep -q "<Operations>" "report.xml"; then
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
else
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.Response;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -70,11 +69,17 @@ public class DevicesController : Controller
}
[HttpGet("")]
public async Task<ListResponseModel<DeviceResponseModel>> Get()
public async Task<ListResponseModel<DeviceAuthRequestResponseModel>> Get()
{
ICollection<Device> devices = await _deviceRepository.GetManyByUserIdAsync(_userService.GetProperUserId(User).Value);
var responses = devices.Select(d => new DeviceResponseModel(d));
return new ListResponseModel<DeviceResponseModel>(responses);
var devicesWithPendingAuthData = await _deviceRepository.GetManyByUserIdWithDeviceAuth(_userService.GetProperUserId(User).Value);
// Convert from DeviceAuthDetails to DeviceAuthRequestResponseModel
var deviceAuthRequestResponseList = devicesWithPendingAuthData
.Select(DeviceAuthRequestResponseModel.From)
.ToList();
var response = new ListResponseModel<DeviceAuthRequestResponseModel>(deviceAuthRequestResponseList);
return response;
}
[HttpPost("")]

View File

@ -1,5 +1,12 @@
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
{
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;

View File

@ -23,7 +23,6 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
public class RegisterUserCommand : IRegisterUserCommand
{
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
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
@ -10,5 +11,9 @@ public interface IDeviceRepository : IRepository<Device, Guid>
Task<Device?> GetByIdentifierAsync(string identifier);
Task<Device?> GetByIdentifierAsync(string identifier, 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);
}

View File

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

View File

@ -1,4 +1,5 @@
using System.Data;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Settings;
@ -11,9 +12,13 @@ namespace Bit.Infrastructure.Dapper.Repositories;
public class DeviceRepository : Repository<Device, Guid>, IDeviceRepository
{
private readonly IGlobalSettings _globalSettings;
public DeviceRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
{
_globalSettings = globalSettings;
}
public DeviceRepository(string connectionString, string 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)
{
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();
parameters.AddDynamicParams(obj);
parameters.Add("Id", obj.Id, direction: ParameterDirection.InputOutput);
var results = await connection.ExecuteAsync(
await connection.ExecuteAsync(
$"[{Schema}].[{Table}_Create]",
parameters,
commandType: CommandType.StoredProcedure);
@ -64,7 +64,7 @@ public abstract class Repository<T, TId> : BaseRepository, IRepository<T, TId>
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
await connection.ExecuteAsync(
$"[{Schema}].[{Table}_Update]",
obj,
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 Bit.Core.Auth.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Infrastructure.EntityFramework.Auth.Repositories.Queries;
using Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@ -10,9 +13,17 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
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)
{ }
{
_globalSettings = globalSettings;
}
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);
}
}
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 Bit.Core.Entities;
using Bit.Core.Test.AutoFixture.UserFixtures;
using Bit.Infrastructure.EFIntegration.Test.Auth.AutoFixture;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture.Relays;
using Bit.Infrastructure.EntityFramework.Auth.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -39,8 +41,10 @@ internal class EfDevice : ICustomization
fixture.Customizations.Add(new GlobalSettingsBuilder());
fixture.Customizations.Add(new DeviceBuilder());
fixture.Customizations.Add(new UserBuilder());
fixture.Customizations.Add(new AuthRequestBuilder());
fixture.Customizations.Add(new EfRepositoryListBuilder<DeviceRepository>());
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
{
[CiSkippedTheory, EfDeviceAutoData]
public async Task CreateAsync_Works_DataMatches(Device device, User user,
DeviceCompare equalityComparer, List<EfRepo.DeviceRepository> suts,
List<EfRepo.UserRepository> efUserRepos, SqlRepo.DeviceRepository sqlDeviceRepo,
public async Task CreateAsync_Works_DataMatches(
Device device,
User user,
DeviceCompare equalityComparer,
List<EfRepo.DeviceRepository> suts,
List<EfRepo.UserRepository> efUserRepos,
SqlRepo.DeviceRepository sqlDeviceRepo,
SqlRepo.UserRepository sqlUserRepo)
{
var savedDevices = new List<Device>();
@ -40,7 +44,6 @@ public class DeviceRepositoryTests
savedDevices.Add(savedSqlDevice);
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
{
private readonly static TimeSpan _userRequestExpiration = TimeSpan.FromMinutes(15);
private readonly static TimeSpan _adminRequestExpiration = TimeSpan.FromDays(6);
private readonly static TimeSpan _afterAdminApprovalExpiration = TimeSpan.FromHours(12);
private static readonly TimeSpan _userRequestExpiration = TimeSpan.FromMinutes(15);
private static readonly TimeSpan _adminRequestExpiration = TimeSpan.FromDays(6);
private static readonly TimeSpan _afterAdminApprovalExpiration = TimeSpan.FromHours(12);
[DatabaseTheory, DatabaseData]
public async Task DeleteExpiredAsync_Works(
@ -25,11 +25,11 @@ public class AuthRequestRepositoryTests
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(
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(
CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, CreateExpiredDate(_adminRequestExpiration)));
@ -37,7 +37,7 @@ public class AuthRequestRepositoryTests
var adminApprovedExpiredAuthRequest = await authRequestRepository.CreateAsync(
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(
CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, CreateExpiredDate(_adminRequestExpiration), false, DateTime.UtcNow.AddHours(-1)));
@ -45,7 +45,7 @@ public class AuthRequestRepositoryTests
var notExpiredUserAuthRequest = await authRequestRepository.CreateAsync(
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(
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)
{
// This is for the device repository integration testing.
var userRequestExpiration = 15;
var configureLogging = (ILoggingBuilder builder) =>
{
if (!config.GetValue<bool>("Quiet"))
@ -67,11 +70,15 @@ public class DatabaseDataAttribute : DataAttribute
{
ConnectionString = database.ConnectionString,
},
PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings
{
UserRequestExpiration = TimeSpan.FromMinutes(userRequestExpiration),
}
};
dapperSqlServerCollection.AddSingleton(globalSettings);
dapperSqlServerCollection.AddSingleton<IGlobalSettings>(globalSettings);
dapperSqlServerCollection.AddSingleton(database);
dapperSqlServerCollection.AddDistributedSqlServerCache((o) =>
dapperSqlServerCollection.AddDistributedSqlServerCache(o =>
{
o.ConnectionString = database.ConnectionString;
o.SchemaName = "dbo";
@ -91,6 +98,17 @@ public class DatabaseDataAttribute : DataAttribute
AddCommonServices(efCollection, configureLogging);
efCollection.SetupEntityFramework(database.ConnectionString, database.Type);
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<IDistributedCache, EntityFrameworkCache>();
@ -117,7 +135,7 @@ public class DatabaseDataAttribute : DataAttribute
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)

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;