Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Customers;
using Grand.Business.Core.Utilities.Customers;
using Grand.Data;
using Grand.Domain.Customers;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -62,7 +61,7 @@ await links.DeleteManyAsync(x =>

//also writes a new password token
var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
await customerManagerService.ChangePassword(new ChangePasswordRequest(customer.Email, PasswordFormat.Hashed, password));
await customerManagerService.ChangePassword(customer, PasswordFormat.Hashed, password);
logger.LogInformation("SCIM reset the credentials of customer {CustomerId}", customer.Id);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,12 @@ Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password
/// <param name="request">Request</param>
/// <param name="storeId">Store identifier - used to resolve the customer when per-store customer identity is enabled</param>
Task ChangePassword(ChangePasswordRequest request, string storeId = "");

/// <summary>
/// Change password of this customer, without resolving the account by e-mail
/// </summary>
/// <param name="customer">Customer</param>
/// <param name="passwordFormat">Password format</param>
/// <param name="newPassword">New password</param>
Task ChangePassword(Customer customer, PasswordFormat passwordFormat, string newPassword);
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ Task UpdateCustomerField<T>(string customerId,
/// <param name="customer">Customer</param>
Task UpdateActive(Customer customer);

/// <summary>
/// Updates the password fields of the customer, and nothing else
/// </summary>
/// <param name="customer">Customer</param>
Task UpdateCustomerPassword(Customer customer);

/// <summary>
/// Update the customer
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,33 +193,46 @@ public virtual async Task ChangePassword(ChangePasswordRequest request, string s
ArgumentNullException.ThrowIfNull(request);

var customer = await _customerService.GetCustomerByEmail(request.Email, storeId);

await ChangePassword(customer, request.PasswordFormat, request.NewPassword);
}

/// <summary>
/// Change password of this customer, without resolving the account by e-mail
/// </summary>
/// <param name="customer">Customer</param>
/// <param name="passwordFormat">Password format</param>
/// <param name="newPassword">New password</param>
public virtual async Task ChangePassword(Customer customer, PasswordFormat passwordFormat, string newPassword)
Comment thread
KrzysztofPajak marked this conversation as resolved.
Resolved
{
ArgumentNullException.ThrowIfNull(customer);

switch (request.PasswordFormat)
switch (passwordFormat)
{
case PasswordFormat.Clear:
{
customer.Password = request.NewPassword;
customer.Password = newPassword;
}
break;
case PasswordFormat.Encrypted:
{
customer.PasswordSalt = CommonHelper.GenerateRandomDigitCode(24);
customer.Password = _encryptionService.EncryptText(request.NewPassword, customer.PasswordSalt);
customer.Password = _encryptionService.EncryptText(newPassword, customer.PasswordSalt);
}
break;
case PasswordFormat.Hashed:
{
//modern self-describing PBKDF2 hash - salt and parameters are embedded in the value itself
customer.PasswordSalt = string.Empty;
customer.Password = _encryptionService.HashPassword(request.NewPassword);
customer.Password = _encryptionService.HashPassword(newPassword);
}
break;
}

customer.PasswordChangeDateUtc = DateTime.UtcNow;
customer.PasswordFormatId = request.PasswordFormat;
await _customerService.UpdateCustomer(customer);
customer.PasswordFormatId = passwordFormat;
//only the password fields: the caller's copy may be older than the account
await _customerService.UpdateCustomerPassword(customer);
//insert password history
await _customerHistoryPasswordService.InsertCustomerPassword(customer);

Expand Down
20 changes: 20 additions & 0 deletions src/Business/Grand.Business.Customers/Services/CustomerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,26 @@ public virtual async Task UpdateCustomerInAdminPanel(Customer customer)
}


public virtual async Task UpdateCustomerPassword(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);

if (customer.IsSystemAccount)
throw new GrandException(
$"System customer account ({(string.IsNullOrEmpty(customer.SystemName) ? customer.Email : customer.SystemName)}) could not be updated");

var update = UpdateBuilder<Customer>.Create()
.Set(x => x.Password, customer.Password)
.Set(x => x.PasswordSalt, customer.PasswordSalt)
.Set(x => x.PasswordFormatId, customer.PasswordFormatId)
.Set(x => x.PasswordChangeDateUtc, customer.PasswordChangeDateUtc);

await _customerRepository.UpdateOneAsync(x => x.Id == customer.Id, update);

//event notification
await _mediator.EntityUpdated(customer);
}

public virtual async Task UpdateActive(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Grand.Domain.Customers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace Grand.Business.Authentication.Tests.Services.Scim;

[TestClass]
public class ScimAccountLockTests
{
[TestMethod]
public async Task ResetCredentials_ChangesThePasswordOfThatAccount()
{
var fixture = new ScimFixture();
var customer = await fixture.AddCustomer("jan@example.com");

await fixture.AccountLock().ResetCredentials(customer);

fixture.CustomerManager.Verify(x => x.ChangePassword(customer, PasswordFormat.Hashed, It.IsAny<string>()),
Times.Once);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ public ScimFixture()
r.Customer.Groups.Add(Registered.Id);
await Customers.UpdateAsync(r.Customer);
});
CustomerManager.Setup(x => x.ChangePassword(It.IsAny<ChangePasswordRequest>(), It.IsAny<string>()))
.Returns(async (ChangePasswordRequest r, string _) =>
CustomerManager.Setup(x => x.ChangePassword(It.IsAny<Customer>(), It.IsAny<PasswordFormat>(), It.IsAny<string>()))
.Returns(async (Customer c, PasswordFormat _, string newPassword) =>
{
var stored = await Customers.GetOneAsync(x => x.Email == r.Email);
stored.Password = "hash:" + r.NewPassword;
var stored = await Customers.GetOneAsync(x => x.Id == c.Id);
stored.Password = "hash:" + newPassword;
stored.UserFields = stored.UserFields.Where(x => x.Key != SystemCustomerFieldNames.PasswordToken).ToList();
stored.UserFields.Add(new UserField {
Key = SystemCustomerFieldNames.PasswordToken, Value = Guid.NewGuid().ToString(), StoreId = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ public async Task Create_ExistingStaffAccount_KeepsCredentials()
await _f.UserService().Create(ScimFixture.Data());

Assert.AreEqual("hash:staff", (await _f.Reload(staff.Id)).Password);
_f.CustomerManager.Verify(x => x.ChangePassword(It.IsAny<ChangePasswordRequest>(), It.IsAny<string>()), Times.Never);
_f.CustomerManager.Verify(x => x.ChangePassword(It.IsAny<Customer>(), It.IsAny<PasswordFormat>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,31 @@ public async Task ChangePassword_PassesStoreIdToLookup()
_customerServiceMock.Verify(c => c.GetCustomerByEmail("admin@admin.com", "store-1"), Times.Once);
}

[TestMethod]
public async Task ChangePassword_ForCustomer_ChangesThatCustomerWithoutLookup()
{
//Arrange
var customer = new Customer { Id = "c1", Email = "a@x.com", PasswordFormatId = PasswordFormat.Clear, Password = "123456" };
//Act
await _customerManagerService.ChangePassword(customer, PasswordFormat.Clear, "zxcvbn");
//Assert
Assert.AreEqual("zxcvbn", customer.Password);
_customerServiceMock.Verify(c => c.UpdateCustomerPassword(customer), Times.Once);
//the whole-customer update would write back everything else the caller's copy holds
_customerServiceMock.Verify(c => c.UpdateCustomer(It.IsAny<Customer>()), Times.Never);
_customerServiceMock.Verify(c => c.GetCustomerByEmail(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
public async Task ChangePassword_UnknownEmail_Throws()
{
//Arrange
var changepassword = new ChangePasswordRequest("nobody@x.com", PasswordFormat.Clear, "zxcvbn");
//Act + Assert
await Assert.ThrowsExactlyAsync<ArgumentNullException>(() =>
_customerManagerService.ChangePassword(changepassword, "store-1"));
}

[TestMethod]
public async Task ChangePasswordTest_Success()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Grand.Infrastructure.Configuration;
using Grand.Infrastructure.Tests.Caching;
using Grand.Mediator;
using Grand.SharedKernel;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

Expand Down Expand Up @@ -523,6 +524,43 @@ public async Task UpdateCustomerInAdminPanel_SavesTradeCredit()
Assert.AreEqual(45, updated.PaymentTermDays);
}

[TestMethod]
public async Task UpdateCustomerPassword_WritesOnlyThePasswordFields()
{
//Arrange
await _repository.InsertAsync(new Customer {
Id = "c1", Email = "current@x.com", Username = "current", StoreId = "store-1", Active = false,
Password = "old", PasswordSalt = "salt", PasswordFormatId = PasswordFormat.Clear
});
//taken before the account changed, as a caller holds it during a request
var changedAt = new DateTime(2026, 11, 1, 12, 0, 0, DateTimeKind.Utc);
var snapshot = new Customer {
Id = "c1", Email = "stale@x.com", Username = "stale", StoreId = "store-2", Active = true,
Password = "new", PasswordSalt = "", PasswordFormatId = PasswordFormat.Hashed,
PasswordChangeDateUtc = changedAt
};
//Act
await _customerService.UpdateCustomerPassword(snapshot);
//Assert
var stored = _repository.Table.First(x => x.Id == "c1");
Assert.AreEqual("new", stored.Password);
Assert.AreEqual("", stored.PasswordSalt);
Assert.AreEqual(PasswordFormat.Hashed, stored.PasswordFormatId);
Assert.AreEqual(changedAt, stored.PasswordChangeDateUtc);
Assert.AreEqual("current@x.com", stored.Email);
Assert.AreEqual("current", stored.Username);
Assert.AreEqual("store-1", stored.StoreId);
Assert.IsFalse(stored.Active);
}

[TestMethod]
public async Task UpdateCustomerPassword_SystemAccount_Throws()
{
var customer = new Customer { Id = "c1", IsSystemAccount = true, SystemName = "system" };

await Assert.ThrowsExactlyAsync<GrandException>(() => _customerService.UpdateCustomerPassword(customer));
}

[TestMethod]
public async Task UpdateActiveTest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ private Customer SubAccount(string ownerId = "owner")
return customer;
}

private void VerifyPasswordChangedOn(Customer customer)
{
_customerManagerServiceMock.Verify(m => m.ChangePassword(customer, It.IsAny<PasswordFormat>(), "newpass"),
Times.Once);
//never through a lookup by e-mail, which may lead to another account
_customerManagerServiceMock.Verify(m => m.ChangePassword(It.IsAny<ChangePasswordRequest>(),
It.IsAny<string>()), Times.Never);
}

private static SubAccountEditCommand Command(string email = "sub@x.com", string password = null,
SubAccountRole role = SubAccountRole.Buyer, string limit = null) => new() {
CurrentCustomer = new Customer { Id = "owner" },
Expand All @@ -63,6 +72,8 @@ private void VerifyNothingWritten()
It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_customerManagerServiceMock.Verify(m => m.ChangePassword(It.IsAny<ChangePasswordRequest>(),
It.IsAny<string>()), Times.Never);
_customerManagerServiceMock.Verify(m => m.ChangePassword(It.IsAny<Customer>(), It.IsAny<PasswordFormat>(),
It.IsAny<string>()), Times.Never);
_customerServiceMock.Verify(c => c.UpdateCustomerField(It.IsAny<Customer>(),
It.IsAny<Expression<Func<Customer, SubAccountRole>>>(), It.IsAny<SubAccountRole>()), Times.Never);
_customerServiceMock.Verify(c => c.UpdateCustomerField(It.IsAny<Customer>(),
Expand Down Expand Up @@ -129,16 +140,17 @@ public async Task Handle_Approver_ClearsLimit()
}

[TestMethod]
public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore()
public async Task Handle_WithPassword_ChangesPasswordOfTheEditedAccount()
{
SubAccount();
var customer = SubAccount();
//another account holding the same address must not matter
_customerServiceMock.Setup(c => c.GetCustomerByEmail("sub@x.com", "store-9"))
.ReturnsAsync(new Customer { Id = "other", Email = "sub@x.com", StoreId = "store-9" });

var result = await _handler.Handle(Command(password: "newpass"), default);

Assert.IsTrue(result);
//the sub-account's store must scope the password change (safe with or without per-store identity)
_customerManagerServiceMock.Verify(m => m.ChangePassword(
It.Is<ChangePasswordRequest>(r => r.Email == "sub@x.com"), "store-9"), Times.Once);
VerifyPasswordChangedOn(customer);
}

[TestMethod]
Expand All @@ -151,6 +163,8 @@ public async Task Handle_WithoutPassword_DoesNotChangePassword()
Assert.IsTrue(result);
_customerManagerServiceMock.Verify(m => m.ChangePassword(
It.IsAny<ChangePasswordRequest>(), It.IsAny<string>()), Times.Never);
_customerManagerServiceMock.Verify(m => m.ChangePassword(
It.IsAny<Customer>(), It.IsAny<PasswordFormat>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
Expand Down Expand Up @@ -210,14 +224,14 @@ public async Task Handle_MixedCaseEmail_IsStoredLowercased()
}

[TestMethod]
public async Task Handle_NewEmailAndPassword_ChangesPasswordOfTheNewEmail()
public async Task Handle_NewEmailAndPassword_ChangesPasswordOfTheEditedAccount()
{
SubAccount();
var customer = SubAccount();

await _handler.Handle(Command(email: "New@X.com", password: "newpass"), default);

_customerManagerServiceMock.Verify(m => m.ChangePassword(
It.Is<ChangePasswordRequest>(r => r.Email == "new@x.com"), "store-9"), Times.Once);
Assert.AreEqual("new@x.com", customer.Email);
VerifyPasswordChangedOn(customer);
}

[TestMethod]
Expand Down Expand Up @@ -253,8 +267,7 @@ public async Task Handle_EmailStoredInMixedCaseByOlderEdits_IsRepairedEvenWhenCh
await _handler.Handle(Command(email: "Sub@X.com", password: "newpass"), default);

Assert.AreEqual("sub@x.com", customer.Email);
_customerManagerServiceMock.Verify(m => m.ChangePassword(
It.Is<ChangePasswordRequest>(r => r.Email == "sub@x.com"), "store-9"), Times.Once);
VerifyPasswordChangedOn(customer);
}

[TestMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ public void Init()
_controller.Url = url.Object;
}

[TestMethod]
public async Task ChangePassword_ChangesThePasswordOfTheSignedInAccount()
{
await _controller.ChangePassword(new ChangePasswordModel { OldPassword = "old", NewPassword = "new" },
new OidcLoginConfig());

_customerManager.Verify(m => m.ChangePassword(_currentCustomer, It.IsAny<PasswordFormat>(), "new"), Times.Once);
_customerManager.Verify(m => m.ChangePassword(It.IsAny<ChangePasswordRequest>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
public async Task Login_ProviderOnly_RedirectsToProvider()
{
Expand Down Expand Up @@ -295,7 +305,8 @@ public async Task ChangePassword_ProviderOnly_Redirects()
var post = await _controller.ChangePassword(
new ChangePasswordModel { OldPassword = "a", NewPassword = "b", ConfirmNewPassword = "b" }, _providerOnly);
Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName);
_customerManager.Verify(m => m.ChangePassword(It.IsAny<ChangePasswordRequest>()), Times.Never);
_customerManager.Verify(m => m.ChangePassword(It.IsAny<ChangePasswordRequest>(), It.IsAny<string>()), Times.Never);
_customerManager.Verify(m => m.ChangePassword(It.IsAny<Customer>(), It.IsAny<PasswordFormat>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
Expand All @@ -306,7 +317,7 @@ public async Task ChangePassword_Mixed_ChangesPassword()
new ChangePasswordModel { OldPassword = "a", NewPassword = "b", ConfirmNewPassword = "b" },
new OidcLoginConfig());
Assert.IsInstanceOfType<ViewResult>(post);
_customerManager.Verify(m => m.ChangePassword(It.IsAny<ChangePasswordRequest>()), Times.Once);
_customerManager.Verify(m => m.ChangePassword(_currentCustomer, It.IsAny<PasswordFormat>(), "b"), Times.Once);
}

[TestMethod]
Expand Down
Loading
Loading