mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 12:26:46 -05:00
* test(org-user-request-model): Add model validation tests. * feat(request-models): Add Authentication and Unlock Data fields with annotations. * test(recover-command): Add tests for Authentication and Unlock Data payload signature. * feat(recover-command): Add overload for Authentication and Unlock Data payload signature. * test(recover-command): Add tests for behavior with authentication and unlock data. * feat(recover-command): Add impl for hash and key, authentication and unlock data inputs. * test(org-users-controller): Add controller tests for dispatch. * feat(org-users-controller): Add controller impl for dispatch for both request payload variants. * chore: lint. * fix(request-model): Validation method drifted in base; rename. * test(request-model): Update validation tests. * feat(request-model): Support 2FA-only validation at the boundary. * test(request-model): Express handling of v1 vs v2 requests. * PM-35394 - Per reviewer's request, mark AdminRecoverAccountCommand.RecoverAccountAsync that doesn't accept new models obselete * PM-35394 - Fix using directive after model namespace move Merge from main moved OrganizationUserResetPasswordRequestModel to the AdminConsole namespace; update the test's using directive to match, restoring both the build and dotnet format checks. --------- Co-authored-by: Jared Snider <jsnider@bitwarden.com>
256 lines
8.3 KiB
C#
256 lines
8.3 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.KeyManagement.Models.Api.Request;
|
|
using Bit.Test.Common.AutoFixture.Attributes;
|
|
using Xunit;
|
|
|
|
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
|
|
|
|
public class OrganizationUserResetPasswordRequestModelTests
|
|
{
|
|
[Theory]
|
|
[BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)]
|
|
[BitAutoData(KdfType.Argon2id, 3, 64, 4)]
|
|
public void Validate_UnlockAndAuthenticationDataOnly_NoErrors(
|
|
KdfType kdfType, int iterations, int? memory, int? parallelism)
|
|
{
|
|
var kdf = new KdfRequestModel
|
|
{
|
|
KdfType = kdfType,
|
|
Iterations = iterations,
|
|
Memory = memory,
|
|
Parallelism = parallelism
|
|
};
|
|
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
ResetMasterPassword = true,
|
|
AuthenticationData = new MasterPasswordAuthenticationDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterPasswordAuthenticationHash = "authHash",
|
|
Salt = "salt"
|
|
},
|
|
UnlockData = new MasterPasswordUnlockDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterKeyWrappedUserKey = "wrappedKey",
|
|
Salt = "salt"
|
|
}
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_UnlockAndAuthenticationDataOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError()
|
|
{
|
|
var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 };
|
|
var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 };
|
|
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
ResetMasterPassword = true,
|
|
AuthenticationData = new MasterPasswordAuthenticationDataRequestModel
|
|
{
|
|
Kdf = authKdf,
|
|
MasterPasswordAuthenticationHash = "authHash",
|
|
Salt = "salt"
|
|
},
|
|
UnlockData = new MasterPasswordUnlockDataRequestModel
|
|
{
|
|
Kdf = unlockKdf,
|
|
MasterKeyWrappedUserKey = "wrappedKey",
|
|
Salt = "salt"
|
|
}
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Single(result);
|
|
Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_UnlockAndAuthenticationDataOnly_WithMismatchedSalts_ReturnsSaltValidationError()
|
|
{
|
|
var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 };
|
|
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
ResetMasterPassword = true,
|
|
AuthenticationData = new MasterPasswordAuthenticationDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterPasswordAuthenticationHash = "authHash",
|
|
Salt = "salt-auth"
|
|
},
|
|
UnlockData = new MasterPasswordUnlockDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterKeyWrappedUserKey = "wrappedKey",
|
|
Salt = "salt-unlock"
|
|
}
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("Invalid master password salt.", result[0].ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_NoPayloadsProvided_ReturnsError()
|
|
{
|
|
var model = new OrganizationUserResetPasswordRequestModel { ResetMasterPassword = true };
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Single(result);
|
|
Assert.Contains("Must provide either", result[0].ErrorMessage);
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData(true)]
|
|
[BitAutoData(false)]
|
|
public void Validate_OnlyOneOfUnlockAndAuthenticationData_ReturnsError(bool provideAuthenticationData)
|
|
{
|
|
var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 };
|
|
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
ResetMasterPassword = true,
|
|
AuthenticationData = provideAuthenticationData
|
|
? new MasterPasswordAuthenticationDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterPasswordAuthenticationHash = "authHash",
|
|
Salt = "salt"
|
|
}
|
|
: null,
|
|
UnlockData = !provideAuthenticationData
|
|
? new MasterPasswordUnlockDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterKeyWrappedUserKey = "wrappedKey",
|
|
Salt = "salt"
|
|
}
|
|
: null
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Single(result);
|
|
Assert.Contains("Must provide either", result[0].ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_TwoFactorOnlyReset_NoPayload_NoErrors()
|
|
{
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
ResetMasterPassword = false,
|
|
ResetTwoFactor = true
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData]
|
|
public void Validate_V1LegacyRequest_HashAndKey_NoErrors(string newHash, string key)
|
|
{
|
|
// v1 clients do not send ResetMasterPassword; it defaults to false.
|
|
// Hash+key payload always present on v1 — validation should pass.
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
NewMasterPasswordHash = newHash,
|
|
Key = key
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void RequestHasNewDataTypes_WithUnlockAndAuthenticationData_ReturnsTrue()
|
|
{
|
|
var kdf = new KdfRequestModel
|
|
{
|
|
KdfType = KdfType.PBKDF2_SHA256,
|
|
Iterations = 600000
|
|
};
|
|
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
AuthenticationData = new MasterPasswordAuthenticationDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterPasswordAuthenticationHash = "authHash",
|
|
Salt = "salt"
|
|
},
|
|
UnlockData = new MasterPasswordUnlockDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterKeyWrappedUserKey = "wrappedKey",
|
|
Salt = "salt"
|
|
}
|
|
};
|
|
|
|
Assert.True(model.RequestHasNewDataTypes());
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData]
|
|
public void RequestHasNewDataTypes_WithHashAndKeyOnly_ReturnsFalse(string newHash, string key)
|
|
{
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
NewMasterPasswordHash = newHash,
|
|
Key = key
|
|
};
|
|
|
|
Assert.False(model.RequestHasNewDataTypes());
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_WhenBothAuthAndUnlockPresent_WithBelowMinimumKdf_NoError()
|
|
{
|
|
// Regression guard: legacy users with sub-minimum KDF settings must be able to have
|
|
// their master password recovered by an admin. KDF strength is enforced upstream
|
|
// (registration, KDF change), NOT at the account-recovery request model level.
|
|
var kdf = new KdfRequestModel
|
|
{
|
|
KdfType = KdfType.PBKDF2_SHA256,
|
|
Iterations = 1
|
|
};
|
|
|
|
var model = new OrganizationUserResetPasswordRequestModel
|
|
{
|
|
ResetMasterPassword = true,
|
|
AuthenticationData = new MasterPasswordAuthenticationDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterPasswordAuthenticationHash = "authHash",
|
|
Salt = "salt"
|
|
},
|
|
UnlockData = new MasterPasswordUnlockDataRequestModel
|
|
{
|
|
Kdf = kdf,
|
|
MasterKeyWrappedUserKey = "wrappedKey",
|
|
Salt = "salt"
|
|
}
|
|
};
|
|
|
|
var result = model.Validate(new ValidationContext(model)).ToList();
|
|
|
|
Assert.DoesNotContain(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between"));
|
|
}
|
|
}
|