[PM-23249] Prevent log-out when changing KDF settings (#6349)

* Prevent log-out when changing KDF settings with feature flag.

* validate salt unchanged for user to throw bad request (400), not internal server error (500)

* change kdf integration tests

* failing tests

* iuncorrect tests wording

* conditional logout

* log out reason as enum

* explicit naming
This commit is contained in:
Maciej Zieniuk 2025-10-21 19:03:25 +02:00 committed by GitHub
parent 8d52ae869c
commit 6324f692b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 675 additions and 115 deletions

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Enums;
public enum PushNotificationLogOutReason : byte
{
KdfChange = 0
}

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
@ -18,17 +19,22 @@ public class ChangeKdfCommand : IChangeKdfCommand
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IdentityErrorDescriber _identityErrorDescriber; private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly ILogger<ChangeKdfCommand> _logger; private readonly ILogger<ChangeKdfCommand> _logger;
private readonly IFeatureService _featureService;
public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, IUserRepository userRepository, IdentityErrorDescriber describer, ILogger<ChangeKdfCommand> logger) public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService,
IUserRepository userRepository, IdentityErrorDescriber describer, ILogger<ChangeKdfCommand> logger,
IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
_pushService = pushService; _pushService = pushService;
_userRepository = userRepository; _userRepository = userRepository;
_identityErrorDescriber = describer; _identityErrorDescriber = describer;
_logger = logger; _logger = logger;
_featureService = featureService;
} }
public async Task<IdentityResult> ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData) public async Task<IdentityResult> ChangeKdfAsync(User user, string masterPasswordAuthenticationHash,
MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData)
{ {
ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(user);
if (!await _userService.CheckPasswordAsync(user, masterPasswordAuthenticationHash)) if (!await _userService.CheckPasswordAsync(user, masterPasswordAuthenticationHash))
@ -47,12 +53,15 @@ public class ChangeKdfCommand : IChangeKdfCommand
{ {
throw new BadRequestException("KDF settings must be equal for authentication and unlock."); throw new BadRequestException("KDF settings must be equal for authentication and unlock.");
} }
var validationErrors = KdfSettingsValidator.Validate(unlockData.Kdf); var validationErrors = KdfSettingsValidator.Validate(unlockData.Kdf);
if (validationErrors.Any()) if (validationErrors.Any())
{ {
throw new BadRequestException("KDF settings are invalid."); throw new BadRequestException("KDF settings are invalid.");
} }
var logoutOnKdfChange = !_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
// Update the user with the new KDF settings // Update the user with the new KDF settings
// This updates the authentication data and unlock data for the user separately. Currently these still // This updates the authentication data and unlock data for the user separately. Currently these still
// use shared values for KDF settings and salt. // use shared values for KDF settings and salt.
@ -68,7 +77,8 @@ public class ChangeKdfCommand : IChangeKdfCommand
// This entire operation MUST be atomic to prevent a user from being locked out of their account. // This entire operation MUST be atomic to prevent a user from being locked out of their account.
// Salt is ensured to be the same as unlock data, and the value stored in the account and not updated. // Salt is ensured to be the same as unlock data, and the value stored in the account and not updated.
// KDF is ensured to be the same as unlock data above and updated below. // KDF is ensured to be the same as unlock data above and updated below.
var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash); var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash,
refreshStamp: logoutOnKdfChange);
if (!result.Succeeded) if (!result.Succeeded)
{ {
_logger.LogWarning("Change KDF failed for user {userId}.", user.Id); _logger.LogWarning("Change KDF failed for user {userId}.", user.Id);
@ -88,7 +98,17 @@ public class ChangeKdfCommand : IChangeKdfCommand
user.LastKdfChangeDate = now; user.LastKdfChangeDate = now;
await _userRepository.ReplaceAsync(user); await _userRepository.ReplaceAsync(user);
if (logoutOnKdfChange)
{
await _pushService.PushLogOutAsync(user.Id); await _pushService.PushLogOutAsync(user.Id);
}
else
{
// Clients that support the new feature flag will ignore the logout when it matches the reason and the feature flag is enabled.
await _pushService.PushLogOutAsync(user.Id, reason: PushNotificationLogOutReason.KdfChange);
await _pushService.PushSyncSettingsAsync(user.Id);
}
return IdentityResult.Success; return IdentityResult.Success;
} }
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions;
namespace Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.KeyManagement.Models.Data;
@ -12,7 +13,7 @@ public class MasterPasswordAuthenticationData
{ {
if (user.GetMasterPasswordSalt() != Salt) if (user.GetMasterPasswordSalt() != Salt)
{ {
throw new ArgumentException("Invalid master password salt."); throw new BadRequestException("Invalid master password salt.");
} }
} }
} }

View File

@ -1,6 +1,5 @@
#nullable enable using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Entities;
namespace Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.KeyManagement.Models.Data;
@ -14,7 +13,7 @@ public class MasterPasswordUnlockData
{ {
if (user.GetMasterPasswordSalt() != Salt) if (user.GetMasterPasswordSalt() != Salt)
{ {
throw new ArgumentException("Invalid master password salt."); throw new BadRequestException("Invalid master password salt.");
} }
} }
} }

View File

@ -97,3 +97,9 @@ public class ProviderBankAccountVerifiedPushNotification
public Guid ProviderId { get; set; } public Guid ProviderId { get; set; }
public Guid AdminId { get; set; } public Guid AdminId { get; set; }
} }
public class LogOutPushNotification
{
public Guid UserId { get; set; }
public PushNotificationLogOutReason? Reason { get; set; }
}

View File

@ -167,18 +167,17 @@ public interface IPushNotificationService
ExcludeCurrentContext = false, ExcludeCurrentContext = false,
}); });
Task PushLogOutAsync(Guid userId, bool excludeCurrentContextFromPush = false) Task PushLogOutAsync(Guid userId, bool excludeCurrentContextFromPush = false,
=> PushAsync(new PushNotification<UserPushNotification> PushNotificationLogOutReason? reason = null)
=> PushAsync(new PushNotification<LogOutPushNotification>
{ {
Type = PushType.LogOut, Type = PushType.LogOut,
Target = NotificationTarget.User, Target = NotificationTarget.User,
TargetId = userId, TargetId = userId,
Payload = new UserPushNotification Payload = new LogOutPushNotification
{ {
UserId = userId, UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete Reason = reason
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
}, },
ExcludeCurrentContext = excludeCurrentContextFromPush, ExcludeCurrentContext = excludeCurrentContextFromPush,
}); });

View File

@ -55,7 +55,7 @@ public enum PushType : byte
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))] [NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
SyncSettings = 10, SyncSettings = 10,
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))] [NotificationInfo("not-specified", typeof(Models.LogOutPushNotification))]
LogOut = 11, LogOut = 11,
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]

View File

@ -64,7 +64,7 @@ public static class HubHelpers
case PushType.SyncSettings: case PushType.SyncSettings:
case PushType.LogOut: case PushType.LogOut:
var userNotification = var userNotification =
JsonSerializer.Deserialize<PushNotificationData<UserPushNotification>>( JsonSerializer.Deserialize<PushNotificationData<LogOutPushNotification>>(
notificationJson, _deserializerOptions); notificationJson, _deserializerOptions);
await hubContext.Clients.User(userNotification.Payload.UserId.ToString()) await hubContext.Clients.User(userNotification.Payload.UserId.ToString())
.SendAsync(_receiveMessageMethod, userNotification, cancellationToken); .SendAsync(_receiveMessageMethod, userNotification, cancellationToken);

View File

@ -1,31 +1,81 @@
using System.Net.Http.Headers; using System.Net;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Api.IntegrationTest.Controllers; namespace Bit.Api.IntegrationTest.Controllers;
public class AccountsControllerTest : IClassFixture<ApiApplicationFactory> public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{ {
private readonly ApiApplicationFactory _factory; private static readonly string _masterKeyWrappedUserKey =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
public AccountsControllerTest(ApiApplicationFactory factory) => _factory = factory; private static readonly string _masterPasswordHash = "master_password_hash";
private static readonly string _newMasterPasswordHash = "new_master_password_hash";
private static readonly KdfRequestModel _defaultKdfRequest =
new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 };
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private readonly IUserRepository _userRepository;
private readonly IPushNotificationService _pushNotificationService;
private readonly IFeatureService _featureService;
private readonly IPasswordHasher<User> _passwordHasher;
private string _ownerEmail = null!;
public AccountsControllerTest(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IPushNotificationService>(_ => { });
_factory.SubstituteService<IFeatureService>(_ => { });
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
_userRepository = _factory.GetService<IUserRepository>();
_pushNotificationService = _factory.GetService<IPushNotificationService>();
_featureService = _factory.GetService<IFeatureService>();
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
}
public async Task InitializeAsync()
{
_ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_ownerEmail);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Fact] [Fact]
public async Task GetAccountsProfile_success() public async Task GetAccountsProfile_success()
{ {
var tokens = await _factory.LoginWithNewAccount(); await _loginHelper.LoginAsync(_ownerEmail);
var client = _factory.CreateClient();
using var message = new HttpRequestMessage(HttpMethod.Get, "/accounts/profile"); using var message = new HttpRequestMessage(HttpMethod.Get, "/accounts/profile");
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); var response = await _client.SendAsync(message);
var response = await client.SendAsync(message);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var content = await response.Content.ReadFromJsonAsync<ProfileResponseModel>(); var content = await response.Content.ReadFromJsonAsync<ProfileResponseModel>();
Assert.NotNull(content); Assert.NotNull(content);
Assert.Equal("integration-test@bitwarden.com", content.Email); Assert.Equal(_ownerEmail, content.Email);
Assert.NotNull(content.Name); Assert.NotNull(content.Name);
Assert.True(content.EmailVerified); Assert.True(content.EmailVerified);
Assert.False(content.Premium); Assert.False(content.Premium);
@ -35,4 +85,354 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>
Assert.NotNull(content.PrivateKey); Assert.NotNull(content.PrivateKey);
Assert.NotNull(content.SecurityStamp); Assert.NotNull(content.SecurityStamp);
} }
[Theory]
[BitAutoData(KdfType.PBKDF2_SHA256, 600001, null, null)]
[BitAutoData(KdfType.Argon2id, 4, 65, 5)]
public async Task PostKdf_ValidRequestLogoutOnKdfChangeFeatureFlagOff_SuccessLogout(KdfType kdf,
int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var userBeforeKdfChange = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userBeforeKdfChange);
_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange).Returns(false);
await _loginHelper.LoginAsync(_ownerEmail);
var kdfRequest = new KdfRequestModel
{
KdfType = kdf,
Iterations = kdfIterations,
Memory = kdfMemory,
Parallelism = kdfParallelism,
};
var response = await PostKdfWithKdfRequestAsync(kdfRequest);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Validate that the user fields were updated correctly
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(user);
Assert.Equal(kdfRequest.KdfType, user.Kdf);
Assert.Equal(kdfRequest.Iterations, user.KdfIterations);
Assert.Equal(kdfRequest.Memory, user.KdfMemory);
Assert.Equal(kdfRequest.Parallelism, user.KdfParallelism);
Assert.Equal(_masterKeyWrappedUserKey, user.Key);
Assert.NotNull(user.LastKdfChangeDate);
Assert.True(user.LastKdfChangeDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.RevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.AccountRevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.NotEqual(userBeforeKdfChange.SecurityStamp, user.SecurityStamp);
Assert.Equal(PasswordVerificationResult.Success,
_passwordHasher.VerifyHashedPassword(user, user.MasterPassword!, _newMasterPasswordHash));
// Validate push notification
await _pushNotificationService.Received(1).PushLogOutAsync(user.Id);
}
[Theory]
[BitAutoData(KdfType.PBKDF2_SHA256, 600001, null, null)]
[BitAutoData(KdfType.Argon2id, 4, 65, 5)]
public async Task PostKdf_ValidRequestLogoutOnKdfChangeFeatureFlagOn_SuccessSyncAndLogoutWithReason(KdfType kdf,
int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var userBeforeKdfChange = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userBeforeKdfChange);
_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange).Returns(true);
await _loginHelper.LoginAsync(_ownerEmail);
var kdfRequest = new KdfRequestModel
{
KdfType = kdf,
Iterations = kdfIterations,
Memory = kdfMemory,
Parallelism = kdfParallelism,
};
var response = await PostKdfWithKdfRequestAsync(kdfRequest);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Validate that the user fields were updated correctly
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(user);
Assert.Equal(kdfRequest.KdfType, user.Kdf);
Assert.Equal(kdfRequest.Iterations, user.KdfIterations);
Assert.Equal(kdfRequest.Memory, user.KdfMemory);
Assert.Equal(kdfRequest.Parallelism, user.KdfParallelism);
Assert.Equal(_masterKeyWrappedUserKey, user.Key);
Assert.NotNull(user.LastKdfChangeDate);
Assert.True(user.LastKdfChangeDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.RevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.AccountRevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.Equal(userBeforeKdfChange.SecurityStamp, user.SecurityStamp);
Assert.Equal(PasswordVerificationResult.Success,
_passwordHasher.VerifyHashedPassword(user, user.MasterPassword!, _newMasterPasswordHash));
// Validate push notification
await _pushNotificationService.Received(1)
.PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange);
await _pushNotificationService.Received(1).PushSyncSettingsAsync(user.Id);
}
[Fact]
public async Task PostKdf_Unauthorized_ReturnsUnauthorized()
{
// Don't call LoginAsync to test unauthorized access
var response = await PostKdfWithKdfRequestAsync(_defaultKdfRequest);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task PostKdf_AuthenticationDataOrUnlockDataNull_BadRequest(bool authenticationDataNull,
bool unlockDataNull)
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = authenticationDataNull
? null
: new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = unlockDataNull
? null
: new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("AuthenticationData and UnlockData must be provided.", content);
}
[Fact]
public async Task PostKdf_InvalidMasterPasswordHash_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var requestModel = new PasswordRequestModel
{
MasterPasswordHash = "wrong-master-password-hash",
NewMasterPasswordHash = _newMasterPasswordHash,
Key = _masterKeyWrappedUserKey,
AuthenticationData = authenticationData,
UnlockData = unlockData
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf");
message.Content = JsonContent.Create(requestModel);
var response = await _client.SendAsync(message);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Incorrect password", content);
}
[Fact]
public async Task PostKdf_ChangedSaltInAuthenticationData_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = "wrong-salt@bitwarden.com"
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Invalid master password salt.", content);
}
[Fact]
public async Task PostKdf_ChangedSaltInUnlockData_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = "wrong-salt@bitwarden.com"
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Invalid master password salt.", content);
}
[Fact]
public async Task PostKdf_KdfNotMatching_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_001 },
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("KDF settings must be equal for authentication and unlock.", content);
}
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1, null, null)]
[InlineData(KdfType.Argon2id, 4, null, 5)]
[InlineData(KdfType.Argon2id, 4, 65, null)]
public async Task PostKdf_InvalidKdf_BadRequest(KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
await _loginHelper.LoginAsync(_ownerEmail);
var kdfRequest = new KdfRequestModel
{
KdfType = kdf,
Iterations = kdfIterations,
Memory = kdfMemory,
Parallelism = kdfParallelism
};
var response = await PostKdfWithKdfRequestAsync(kdfRequest);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("KDF settings are invalid", content);
}
[Fact]
public async Task PostKdf_InvalidNewMasterPassword_BadRequest()
{
var newMasterPasswordHash = "too-short";
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var requestModel = new PasswordRequestModel
{
MasterPasswordHash = _masterPasswordHash,
NewMasterPasswordHash = newMasterPasswordHash,
Key = _masterKeyWrappedUserKey,
AuthenticationData = authenticationData,
UnlockData = unlockData
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf");
message.Content = JsonContent.Create(requestModel);
var response = await _client.SendAsync(message);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Passwords must be at least", content);
}
private async Task<HttpResponseMessage> PostKdfWithKdfRequestAsync(KdfRequestModel kdfRequest)
{
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = kdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = kdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
return await PostKdfAsync(authenticationData, unlockData);
}
private async Task<HttpResponseMessage> PostKdfAsync(
MasterPasswordAuthenticationDataRequestModel? authenticationDataRequest,
MasterPasswordUnlockDataRequestModel? unlockDataRequest)
{
var requestModel = new PasswordRequestModel
{
MasterPasswordHash = _masterPasswordHash,
NewMasterPasswordHash = _newMasterPasswordHash,
Key = _masterKeyWrappedUserKey,
AuthenticationData = authenticationDataRequest,
UnlockData = unlockDataRequest
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf");
message.Content = JsonContent.Create(requestModel);
return await _client.SendAsync(message);
}
} }

View File

@ -11,6 +11,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -617,6 +618,16 @@ public class AccountsControllerTests : IDisposable
await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user); await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user);
} }
[Theory]
[BitAutoData]
public async Task PostKdf_UserNotFound_ShouldFail(PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult<User>(null));
// Act
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostKdf(model));
}
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PostKdf_WithNullAuthenticationData_ShouldFail( public async Task PostKdf_WithNullAuthenticationData_ShouldFail(
@ -626,7 +637,9 @@ public class AccountsControllerTests : IDisposable
model.AuthenticationData = null; model.AuthenticationData = null;
// Act // Act
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model)); var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
Assert.Contains("AuthenticationData and UnlockData must be provided.", exception.Message);
} }
[Theory] [Theory]
@ -638,7 +651,41 @@ public class AccountsControllerTests : IDisposable
model.UnlockData = null; model.UnlockData = null;
// Act // Act
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model)); var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
Assert.Contains("AuthenticationData and UnlockData must be provided.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task PostKdf_ChangeKdfFailed_ShouldFail(
User user, PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_changeKdfCommand.ChangeKdfAsync(Arg.Any<User>(), Arg.Any<string>(),
Arg.Any<MasterPasswordAuthenticationData>(), Arg.Any<MasterPasswordUnlockData>())
.Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Change KDF failed" })));
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
Assert.NotNull(exception.ModelState);
Assert.Contains("Change KDF failed",
exception.ModelState.Values.SelectMany(x => x.Errors).Select(x => x.ErrorMessage));
}
[Theory]
[BitAutoData]
public async Task PostKdf_ChangeKdfSuccess_NoError(
User user, PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_changeKdfCommand.ChangeKdfAsync(Arg.Any<User>(), Arg.Any<string>(),
Arg.Any<MasterPasswordAuthenticationData>(), Arg.Any<MasterPasswordUnlockData>())
.Returns(Task.FromResult(IdentityResult.Success));
// Act
await _sut.PostKdf(model);
} }
// Below are helper functions that currently belong to this // Below are helper functions that currently belong to this

View File

@ -1,9 +1,11 @@
#nullable enable #nullable enable
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf.Implementations; using Bit.Core.KeyManagement.Kdf.Implementations;
using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
@ -21,16 +23,12 @@ public class ChangeKdfCommandTests
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider<ChangeKdfCommand> sutProvider, User user)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success)); .Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(IdentityResult.Success));
var kdf = new KdfSettings var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
Kdf = kdf, Kdf = kdf,
@ -59,13 +57,7 @@ public class ChangeKdfCommandTests
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider<ChangeKdfCommand> sutProvider) public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider<ChangeKdfCommand> sutProvider)
{ {
var kdf = new KdfSettings var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
Kdf = kdf, Kdf = kdf,
@ -85,17 +77,13 @@ public class ChangeKdfCommandTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider,
User user)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(false)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(false));
var kdf = new KdfSettings var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
Kdf = kdf, Kdf = kdf,
@ -116,7 +104,9 @@ public class ChangeKdfCommandTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOff_UpdatesUserCorrectlyAndLogsOut(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{ {
var constantKdf = new KdfSettings var constantKdf = new KdfSettings
{ {
@ -137,8 +127,12 @@ public class ChangeKdfCommandTests
MasterKeyWrappedUserKey = "new-wrapped-key", MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt() Salt = user.GetMasterPasswordSalt()
}; };
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success)); .Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
.Returns(Task.FromResult(IdentityResult.Success));
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(false);
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
@ -150,17 +144,79 @@ public class ChangeKdfCommandTests
&& u.KdfParallelism == constantKdf.Parallelism && u.KdfParallelism == constantKdf.Parallelism
&& u.Key == "new-wrapped-key" && u.Key == "new-wrapped-key"
)); ));
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: true);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync(user.Id);
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOn_UpdatesUserCorrectlyAndDoesNotLogOut(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); var constantKdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 5,
Memory = 1024,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = constantKdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = constantKdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
.Returns(Task.FromResult(IdentityResult.Success));
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(true);
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == user.Id
&& u.Kdf == constantKdf.KdfType
&& u.KdfIterations == constantKdf.Iterations
&& u.KdfMemory == constantKdf.Memory
&& u.KdfParallelism == constantKdf.Parallelism
&& u.Key == "new-wrapped-key"
));
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: false);
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncSettingsAsync(user.Id);
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }, Kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
},
MasterPasswordAuthenticationHash = "new-auth-hash", MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt() Salt = user.GetMasterPasswordSalt()
}; };
@ -176,9 +232,11 @@ public class ChangeKdfCommandTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf) public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
KdfSettings kdf)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
@ -192,15 +250,17 @@ public class ChangeKdfCommandTests
MasterKeyWrappedUserKey = "new-wrapped-key", MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt() Salt = user.GetMasterPasswordSalt()
}; };
await Assert.ThrowsAsync<ArgumentException>(async () => await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf) public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
KdfSettings kdf)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
@ -214,25 +274,22 @@ public class ChangeKdfCommandTests
MasterKeyWrappedUserKey = "new-wrapped-key", MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = "different-salt" Salt = "different-salt"
}; };
await Assert.ThrowsAsync<ArgumentException>(async () => await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider,
User user)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" });
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(failedResult)); sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(failedResult));
var kdf = new KdfSettings var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData var authenticationData = new MasterPasswordAuthenticationData
{ {
Kdf = kdf, Kdf = kdf,
@ -253,9 +310,11 @@ public class ChangeKdfCommandTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
// Create invalid KDF settings (iterations too low for PBKDF2) // Create invalid KDF settings (iterations too low for PBKDF2)
var invalidKdf = new KdfSettings var invalidKdf = new KdfSettings
@ -287,9 +346,11 @@ public class ChangeKdfCommandTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user) public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{ {
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true)); sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
// Create invalid Argon2 KDF settings (memory too high) // Create invalid Argon2 KDF settings (memory too high)
var invalidKdf = new KdfSettings var invalidKdf = new KdfSettings
@ -318,5 +379,4 @@ public class ChangeKdfCommandTests
Assert.Equal("KDF settings are invalid.", exception.Message); Assert.Equal("KDF settings are invalid.", exception.Message);
} }
} }

View File

@ -358,20 +358,28 @@ public class AzureQueuePushEngineTests
} }
[Theory] [Theory]
[InlineData(true)] [InlineData(true, null)]
[InlineData(false)] [InlineData(true, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext) [InlineData(false, null)]
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var payload = new JsonObject
{
["UserId"] = userId
};
if (reason != null)
{
payload["Reason"] = (int)reason;
}
var expectedPayload = new JsonObject var expectedPayload = new JsonObject
{ {
["Type"] = 11, ["Type"] = 11,
["Payload"] = new JsonObject ["Payload"] = payload,
{
["UserId"] = userId,
["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime,
},
}; };
if (excludeCurrentContext) if (excludeCurrentContext)
@ -380,7 +388,7 @@ public class AzureQueuePushEngineTests
} }
await VerifyNotificationAsync( await VerifyNotificationAsync(
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
expectedPayload expectedPayload
); );
} }

View File

@ -1,6 +1,7 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push.Internal; using Bit.Core.Platform.Push.Internal;
using Bit.Core.Tools.Entities; using Bit.Core.Tools.Entities;
@ -193,7 +194,8 @@ public class NotificationsApiPushEngineTests : PushTestBase
}; };
} }
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext) protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{ {
JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null; JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null;
@ -203,7 +205,7 @@ public class NotificationsApiPushEngineTests : PushTestBase
["Payload"] = new JsonObject ["Payload"] = new JsonObject
{ {
["UserId"] = userId, ["UserId"] = userId,
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, ["Reason"] = reason != null ? (int)reason : null
}, },
["ContextId"] = contextId, ["ContextId"] = contextId,
}; };

View File

@ -86,7 +86,8 @@ public abstract class PushTestBase
protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId); protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId);
protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId); protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId);
protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId); protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId);
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext); protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
PushNotificationLogOutReason? reason);
protected abstract JsonNode GetPushSendCreatePayload(Send send); protected abstract JsonNode GetPushSendCreatePayload(Send send);
protected abstract JsonNode GetPushSendUpdatePayload(Send send); protected abstract JsonNode GetPushSendUpdatePayload(Send send);
protected abstract JsonNode GetPushSendDeletePayload(Send send); protected abstract JsonNode GetPushSendDeletePayload(Send send);
@ -263,15 +264,18 @@ public abstract class PushTestBase
} }
[Theory] [Theory]
[InlineData(true)] [InlineData(true, null)]
[InlineData(false)] [InlineData(true, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext) [InlineData(false, null)]
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
await VerifyNotificationAsync( await VerifyNotificationAsync(
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
GetPushLogOutPayload(userId, excludeCurrentContext) GetPushLogOutPayload(userId, excludeCurrentContext, reason)
); );
} }

View File

@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push.Internal; using Bit.Core.Platform.Push.Internal;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -274,7 +275,8 @@ public class RelayPushNotificationServiceTests : PushTestBase
}; };
} }
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext) protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{ {
JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null; JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null;
@ -288,7 +290,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
["Payload"] = new JsonObject ["Payload"] = new JsonObject
{ {
["UserId"] = userId, ["UserId"] = userId,
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, ["Reason"] = reason != null ? (int)reason : null
}, },
["ClientType"] = null, ["ClientType"] = null,
["InstallationId"] = null, ["InstallationId"] = null,

View File

@ -404,16 +404,18 @@ public class NotificationHubPushNotificationServiceTests
} }
[Theory] [Theory]
[InlineData(true)] [InlineData(true, null)]
[InlineData(false)] [InlineData(true, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext) [InlineData(false, null)]
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext, PushNotificationLogOutReason? reason)
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var expectedPayload = new JsonObject var expectedPayload = new JsonObject
{ {
["UserId"] = userId, ["UserId"] = userId,
["Date"] = _now, ["Reason"] = reason != null ? (int)reason : null,
}; };
var expectedTag = excludeCurrentContext var expectedTag = excludeCurrentContext
@ -421,7 +423,7 @@ public class NotificationHubPushNotificationServiceTests
: $"(template:payload_userId:{userId})"; : $"(template:payload_userId:{userId})";
await VerifyNotificationAsync( await VerifyNotificationAsync(
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
PushType.LogOut, PushType.LogOut,
expectedPayload, expectedPayload,
expectedTag expectedTag

View File

@ -452,7 +452,8 @@ public class ProfileServiceTests
user.SecurityStamp = securityStamp; user.SecurityStamp = securityStamp;
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()), new Claim(Claims.SecurityStamp, securityStamp) new Claim("sub", user.Id.ToString()),
new Claim(Claims.SecurityStamp, securityStamp)
])); ]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user); _userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
@ -486,7 +487,8 @@ public class ProfileServiceTests
user.SecurityStamp = "current-security-stamp"; user.SecurityStamp = "current-security-stamp";
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()), new Claim(Claims.SecurityStamp, "old-security-stamp") new Claim("sub", user.Id.ToString()),
new Claim(Claims.SecurityStamp, "old-security-stamp")
])); ]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user); _userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
@ -517,7 +519,8 @@ public class ProfileServiceTests
user.SecurityStamp = "current-stamp"; user.SecurityStamp = "current-stamp";
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()), new Claim(Claims.SecurityStamp, claimStamp) new Claim("sub", user.Id.ToString()),
new Claim(Claims.SecurityStamp, claimStamp)
])); ]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user); _userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
@ -546,7 +549,8 @@ public class ProfileServiceTests
{ {
context.Client.ClientId = client; context.Client.ClientId = client;
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()), new Claim("email", user.Email) new Claim("sub", user.Id.ToString()),
new Claim("email", user.Email)
])); ]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user); _userService.GetUserByPrincipalAsync(context.Subject).Returns(user);