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
Expand Up @@ -39,4 +39,14 @@ public interface ICustomerManagerService
/// <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>
/// <remarks>The default body keeps implementations built for an earlier 2.4 release loading without a rebuild</remarks>
Task ChangePassword(Customer customer, PasswordFormat passwordFormat, string newPassword) =>
ChangePassword(new ChangePasswordRequest(customer.Email, passwordFormat, newPassword), customer.StoreId);
Comment thread
KrzysztofPajak marked this conversation as resolved.
Resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ 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>
/// <remarks>The default body keeps implementations built for an earlier 2.4 release loading without a rebuild</remarks>
Task UpdateCustomerPassword(Customer customer) => UpdateCustomer(customer);
Comment thread
KrzysztofPajak marked this conversation as resolved.
Resolved

/// <summary>
/// Update the customer
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,33 +173,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)
{
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 @@ -551,6 +551,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
Expand Up @@ -100,6 +100,74 @@ 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_ImplementationBuiltForAnEarlierRelease_UsesItsExistingMethod()
{
ICustomerManagerService manager = new EarlierReleaseManager();

await manager.ChangePassword(new Customer { Email = "a@x.com", StoreId = "store-1" }, PasswordFormat.Hashed, "new");

var implementation = (EarlierReleaseManager)manager;
Assert.AreEqual("a@x.com", implementation.Request.Email);
Assert.AreEqual(PasswordFormat.Hashed, implementation.Request.PasswordFormat);
Assert.AreEqual("new", implementation.Request.NewPassword);
Assert.AreEqual("store-1", implementation.StoreId);
}

[TestMethod]
public void NewInterfaceMembers_HaveDefaultBodies_SoExistingImplementationsKeepLoading()
{
Assert.IsFalse(typeof(ICustomerService).GetMethod(nameof(ICustomerService.UpdateCustomerPassword))!.IsAbstract);
Assert.IsFalse(typeof(ICustomerManagerService).GetMethod(nameof(ICustomerManagerService.ChangePassword),
[typeof(Customer), typeof(PasswordFormat), typeof(string)])!.IsAbstract);
}

private sealed class EarlierReleaseManager : ICustomerManagerService
{
public ChangePasswordRequest Request { get; private set; }
public string StoreId { get; private set; }

public Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password, string storeId = "") =>
throw new NotImplementedException();

public Task RegisterCustomer(RegistrationRequest request) => throw new NotImplementedException();

public bool PasswordMatch(PasswordFormat passwordFormat, string oldPassword, string newPassword,
string passwordSalt) => throw new NotImplementedException();

public Task ChangePassword(ChangePasswordRequest request, string storeId = "")
{
Request = request;
StoreId = storeId;
return Task.CompletedTask;
}
}

[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 @@ -474,6 +475,43 @@ public async Task UpdateCustomerinAdminPanelTest()
Assert.AreEqual("store-1", updated.StoreId);
}

[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
Loading