Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
abfe456
Add store-manager customer management and per-store customer identity
KrzysztofPajak Jun 15, 2026
4334f60
Extend per-store customer identity to the Frontend API
KrzysztofPajak Jun 15, 2026
0b5c191
Disable store customer panel when per-store identity is off
KrzysztofPajak Jun 15, 2026
7240ad7
Add tests for per-store customer identity and store customer panel
KrzysztofPajak Jun 15, 2026
13575de
Isolate storefront login per store when per-store identity is on
KrzysztofPajak Jun 16, 2026
ac7ae9f
Restore default RegisterCustomersPerStore to false
KrzysztofPajak Jun 16, 2026
318594d
Drop per-store login guard, rely on host-only auth cookie
KrzysztofPajak Jun 16, 2026
480f6ae
Fix admin lockout: prefer store-independent account on global lookup
KrzysztofPajak Jun 16, 2026
f0e7f02
Fix admin storefront login and hide Store portal link from non-staff
KrzysztofPajak Jun 16, 2026
806c757
Protect back-office account from by-email delete under per-store iden…
KrzysztofPajak Jun 16, 2026
5d489d2
Scope vendor merchandise-return customer search by store under per-st…
KrzysztofPajak Jun 16, 2026
0e53d1a
Add full branch coverage for GetCustomerByEmail
KrzysztofPajak Jun 16, 2026
9dfc371
Potential fix for pull request finding 'Missed opportunity to use Where'
KrzysztofPajak Jun 16, 2026
6262238
Potential fix for pull request finding 'Generic catch clause'
KrzysztofPajak Jun 16, 2026
d4956a8
Potential fix for pull request finding 'Generic catch clause'
KrzysztofPajak Jun 16, 2026
9cd69ad
Potential fix for pull request finding 'Generic catch clause'
KrzysztofPajak Jun 16, 2026
0dd33b5
Add tests for per-store auth resolution and StoreManager permission
KrzysztofPajak Jun 16, 2026
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 @@ -47,10 +47,16 @@ public virtual async Task<Customer> GetAuthenticatedCustomer()
if (!authenticateResult.Succeeded)
return null;

//try to get customer by email
var emailClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "Email");
if (emailClaim != null)
customer = await _customerService.GetCustomerByEmail(emailClaim.Value);
//prefer the stable customer id (unambiguous with per-store identity), fall back to e-mail for old tokens
var customerIdClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "CustomerId");
if (customerIdClaim != null)
customer = await _customerService.GetCustomerById(customerIdClaim.Value);
else
{
var emailClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "Email");
if (emailClaim != null)
customer = await _customerService.GetCustomerByEmail(emailClaim.Value);
}

//whether the found customer is available
if (customer is not { Active: true } || customer.Deleted || !await _groupService.IsRegistered(customer))
Expand All @@ -75,8 +81,14 @@ private async Task<Customer> ApiCustomer()
if (!authResult.Succeeded)
return await _customerService.GetCustomerBySystemName(SystemCustomerNames.Anonymous);

var customerId = authResult.Principal.Claims.FirstOrDefault(x => x.Type == "CustomerId")?.Value;
var email = authResult.Principal.Claims.FirstOrDefault(x => x.Type == "Email")?.Value;
if (email is null)
if (!string.IsNullOrEmpty(customerId))
{
//prefer the stable customer id - unambiguous with per-store identity
customer = await _customerService.GetCustomerById(customerId);
}
else if (email is null)
{
//guest
var id = authResult.Principal.Claims.FirstOrDefault(x => x.Type == "Guid")?.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@

private string CustomerCookieName => $"{_securityConfig.CookiePrefix}Customer";

//claim carrying the customer's stable id, so the authenticated session can be re-resolved unambiguously
//(works regardless of per-store customer identity - the id is globally unique, unlike e-mail/username)
private const string CustomerIdClaimType = "grand:customerId";

#endregion

#region Fields
Expand All @@ -69,7 +73,7 @@
/// </summary>
/// <param name="customer">Customer</param>
/// <param name="isPersistent">Whether the authentication session is persisted across multiple requests</param>
public virtual async Task SignIn(Customer customer, bool isPersistent)

Check warning on line 76 in src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Rename parameter 'isPersistent' to 'createPersistentCookie' to match the interface declaration.
{
ArgumentNullException.ThrowIfNull(customer);

Expand All @@ -84,6 +88,12 @@
claims.Add(new Claim(ClaimTypes.Email, customer.Email, ClaimValueTypes.Email,
_securityConfig.CookieClaimsIssuer));

//store the customer's stable id so the session is re-resolved by id (GetCustomerById) rather than by
//e-mail/username, which is not unique when per-store customer identity is enabled
if (!string.IsNullOrEmpty(customer.Id))
claims.Add(new Claim(CustomerIdClaimType, customer.Id, ClaimValueTypes.String,
_securityConfig.CookieClaimsIssuer));

//add token
var passwordToken = customer.GetUserFieldFromEntity<string>(SystemCustomerFieldNames.PasswordToken);
if (string.IsNullOrEmpty(passwordToken))
Expand Down Expand Up @@ -154,6 +164,16 @@

private async Task<Customer> RetrieveCustomer(ClaimsPrincipal principal)
{
//prefer the stable customer id - unambiguous even with per-store customer identity
var customerId = principal.FindFirst(claim =>
claim.Type == CustomerIdClaimType &&
claim.Issuer.Equals(_securityConfig.CookieClaimsIssuer, StringComparison.InvariantCultureIgnoreCase))
?.Value;

if (!string.IsNullOrEmpty(customerId))
return await _customerService.GetCustomerById(customerId);

//fallback for sessions issued before the id claim existed (resolved globally, as before)
if (_customerSettings.UsernamesEnabled)
{
var username = principal.FindFirst(claim =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,25 @@
public async Task<bool> Valid(TokenValidatedContext context)
{
if (context.Principal == null) return false;
var customerId = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "CustomerId")?.Value;

Check warning on line 32 in src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Drop this useless call to 'ToList' or replace it by 'AsEnumerable' if you are using LINQ to Entities.

Check warning on line 32 in src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Drop this useless call to 'ToList' or replace it by 'AsEnumerable' if you are using LINQ to Entities.

See more on https://sonarcloud.io/project/issues?id=grandnode_grandnode2&issues=AZ7RTjWE6EJ9mH8oEu-A&open=AZ7RTjWE6EJ9mH8oEu-A&pullRequest=717
var email = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Email")?.Value;

Check warning on line 33 in src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Drop this useless call to 'ToList' or replace it by 'AsEnumerable' if you are using LINQ to Entities.
var passwordToken = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Token")?.Value;

Check warning on line 34 in src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Drop this useless call to 'ToList' or replace it by 'AsEnumerable' if you are using LINQ to Entities.
var refreshId = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "RefreshId")?.Value;

Check warning on line 35 in src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Drop this useless call to 'ToList' or replace it by 'AsEnumerable' if you are using LINQ to Entities.
Customer customer = null;
if (email is null)
if (!string.IsNullOrEmpty(customerId))
{
//prefer the stable customer id - unambiguous with per-store identity
customer = await _customerService.GetCustomerById(customerId);
}
else if (email is null)
{
//guest
var id = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Guid")?.Value;

Check warning on line 45 in src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Drop this useless call to 'ToList' or replace it by 'AsEnumerable' if you are using LINQ to Entities.
if (id != null) customer = await _customerService.GetCustomerByGuid(Guid.Parse(id));
}
else
{
//legacy fallback for tokens issued before the id claim existed
customer = await _customerService.GetCustomerByEmail(email);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ public virtual IEnumerable<DefaultPermission> GetDefaultPermissions()
StandardPermission.ManagePaymentTransactions,
StandardPermission.ManageShipments,
StandardPermission.ManageMerchandiseReturns,
StandardPermission.ManageCustomers,
StandardPermission.ManageReports
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ public interface ICustomerManagerService
/// </summary>
/// <param name="usernameOrEmail">Username or email</param>
/// <param name="password">Password</param>
/// <param name="storeId">Store identifier - used to resolve the customer when per-store customer identity is enabled</param>
/// <returns>Result</returns>
Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password);
Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password, string storeId = "");

/// <summary>
/// Register customer
Expand All @@ -36,5 +37,6 @@ public interface ICustomerManagerService
/// Change password
/// </summary>
/// <param name="request">Request</param>
Task ChangePassword(ChangePasswordRequest request);
/// <param name="storeId">Store identifier - used to resolve the customer when per-store customer identity is enabled</param>
Task ChangePassword(ChangePasswordRequest request, string storeId = "");
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
/// <param name="pageSize">Page size</param>
/// <param name="orderBySelector"></param>
/// <returns>Customers</returns>
Task<IPagedList<Customer>> GetAllCustomers(DateTime? createdFromUtc = null,

Check warning on line 47 in src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Method has 21 parameters, which is greater than the 7 authorized.
DateTime? createdToUtc = null, string affiliateId = "", string vendorId = "", string storeId = "",
string ownerId = "",
string salesEmployeeId = "", string[] customerGroupIds = null, string[] customerTagIds = null,
Expand Down Expand Up @@ -104,11 +104,14 @@
Task<Customer> GetCustomerByGuid(Guid customerGuid);

/// <summary>
/// Get customer by email
/// Get customer by email.
/// When <paramref name="storeId" /> is supplied (per-store customer identity) the lookup is scoped to
/// that store; when it is empty the customer is resolved globally (default behaviour).
/// </summary>
/// <param name="email">Email</param>
/// <param name="storeId">Store identifier (optional)</param>
/// <returns>Customer</returns>
Task<Customer> GetCustomerByEmail(string email);
Task<Customer> GetCustomerByEmail(string email, string storeId = "");

/// <summary>
/// Get customer by system group
Expand All @@ -118,11 +121,14 @@
Task<Customer> GetCustomerBySystemName(string systemName);

/// <summary>
/// Get customer by username
/// Get customer by username.
/// When <paramref name="storeId" /> is supplied (per-store customer identity) the lookup is scoped to
/// that store; when it is empty the customer is resolved globally (default behaviour).
/// </summary>
/// <param name="username">Username</param>
/// <param name="storeId">Store identifier (optional)</param>
/// <returns>Customer</returns>
Task<Customer> GetCustomerByUsername(string username);
Task<Customer> GetCustomerByUsername(string username, string storeId = "");

/// <summary>
/// Insert a guest customer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ public virtual bool PasswordMatch(PasswordFormat passwordFormat, string oldPassw
/// <param name="usernameOrEmail">Username or email</param>
/// <param name="password">Password</param>
/// <returns>Result</returns>
public virtual async Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password)
public virtual async Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password, string storeId = "")
{
var customer = _customerSettings.UsernamesEnabled
? await _customerService.GetCustomerByUsername(usernameOrEmail)
: await _customerService.GetCustomerByEmail(usernameOrEmail);
? await _customerService.GetCustomerByUsername(usernameOrEmail, storeId)
: await _customerService.GetCustomerByEmail(usernameOrEmail, storeId);

var pwd = customer.PasswordFormatId switch {
PasswordFormat.Clear => password,
Expand Down Expand Up @@ -168,11 +168,11 @@ public virtual async Task RegisterCustomer(RegistrationRequest request)
/// Change password
/// </summary>
/// <param name="request">Request</param>
public virtual async Task ChangePassword(ChangePasswordRequest request)
public virtual async Task ChangePassword(ChangePasswordRequest request, string storeId = "")
{
ArgumentNullException.ThrowIfNull(request);

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

switch (request.PasswordFormat)
Expand Down
64 changes: 58 additions & 6 deletions src/Business/Grand.Business.Customers/Services/CustomerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Grand.Domain.Shipping;
using Grand.Infrastructure.Caching;
using Grand.Infrastructure.Caching.Constants;
using Grand.Infrastructure.Configuration;
using Grand.Infrastructure.Extensions;
using Grand.SharedKernel;
using Grand.SharedKernel.Extensions;
Expand All @@ -26,11 +27,13 @@ public class CustomerService : ICustomerService
public CustomerService(
IRepository<Customer> customerRepository,
IMediator mediator,
ICacheBase cacheBase)
ICacheBase cacheBase,
CustomerConfig customerConfig)
{
_customerRepository = customerRepository;
_mediator = mediator;
_cacheBase = cacheBase;
_customerConfig = customerConfig;
}

#endregion
Expand All @@ -40,6 +43,7 @@ public CustomerService(
private readonly IRepository<Customer> _customerRepository;
private readonly IMediator _mediator;
private readonly ICacheBase _cacheBase;
private readonly CustomerConfig _customerConfig;

#endregion

Expand Down Expand Up @@ -224,9 +228,36 @@ public virtual async Task<Customer> GetCustomerByGuid(Guid customerGuid)
/// </summary>
/// <param name="email">Email</param>
/// <returns>Customer</returns>
public virtual Task<Customer> GetCustomerByEmail(string email)
public virtual async Task<Customer> GetCustomerByEmail(string email, string storeId = "")
{
return string.IsNullOrWhiteSpace(email) ? Task.FromResult<Customer>(null) : _customerRepository.GetOneAsync(x => x.Email == email.ToLowerInvariant());
if (string.IsNullOrWhiteSpace(email))
return null;

var loweredEmail = email.ToLowerInvariant();
if (!string.IsNullOrEmpty(storeId))
{
var inStore = await _customerRepository.GetOneAsync(x => x.Email == loweredEmail && x.StoreId == storeId);
if (inStore != null || !_customerConfig.RegisterCustomersPerStore)
return inStore;

//a store-scoped lookup (e.g. storefront login) must still reach the store-independent
//system/back-office account (administrator, created without a store) so it can sign in too
return await _customerRepository.GetOneAsync(x =>
x.Email == loweredEmail && (x.StoreId == null || x.StoreId == ""));
}

//global (store-independent) lookup. With per-store identity the same email may exist in several
//stores, so prefer the store-independent account (system/admin/back-office created without a store)
//to make sure e.g. admin panel login is not shadowed by a store customer that reused the email.
if (_customerConfig.RegisterCustomersPerStore)
{
var storeless = await _customerRepository.GetOneAsync(x =>
x.Email == loweredEmail && (x.StoreId == null || x.StoreId == ""));
if (storeless != null)
return storeless;
}

return await _customerRepository.GetOneAsync(x => x.Email == loweredEmail);
}

/// <summary>
Expand All @@ -249,12 +280,33 @@ public virtual Task<Customer> GetCustomerBySystemName(string systemName)
/// </summary>
/// <param name="username">Username</param>
/// <returns>Customer</returns>
public virtual Task<Customer> GetCustomerByUsername(string username)
public virtual async Task<Customer> GetCustomerByUsername(string username, string storeId = "")
{
if (string.IsNullOrWhiteSpace(username))
return Task.FromResult<Customer>(null);
return null;

var loweredUsername = username.ToLowerInvariant();
if (!string.IsNullOrEmpty(storeId))
{
var inStore = await _customerRepository.GetOneAsync(x => x.Username == loweredUsername && x.StoreId == storeId);
if (inStore != null || !_customerConfig.RegisterCustomersPerStore)
return inStore;

//a store-scoped lookup must still reach the store-independent system/back-office account
return await _customerRepository.GetOneAsync(x =>
x.Username == loweredUsername && (x.StoreId == null || x.StoreId == ""));
}

//global (store-independent) lookup - prefer the store-independent account (see GetCustomerByEmail)
if (_customerConfig.RegisterCustomersPerStore)
{
var storeless = await _customerRepository.GetOneAsync(x =>
x.Username == loweredUsername && (x.StoreId == null || x.StoreId == ""));
if (storeless != null)
return storeless;
}

return _customerRepository.GetOneAsync(x => x.Username == username.ToLowerInvariant());
return await _customerRepository.GetOneAsync(x => x.Username == loweredUsername);
}

/// <summary>
Expand Down
30 changes: 30 additions & 0 deletions src/Core/Grand.Infrastructure/Configuration/CustomerConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Grand.Infrastructure.Configuration;

/// <summary>
/// Represents a Customer Config (bound from the "Customer" section of appsettings.json)
/// </summary>
public class CustomerConfig
{
/// <summary>
/// Gets or sets a value indicating whether customer accounts are scoped per store.
/// <para>
/// When <c>false</c> (default), the customer uniqueness key is the e-mail address (and username)
/// alone — an e-mail can only ever belong to a single customer across the whole installation.
/// </para>
/// <para>
/// When <c>true</c>, the uniqueness key becomes the pair (e-mail / username + <c>StoreId</c>).
/// The same e-mail address may therefore be registered independently in two different stores as
/// two separate, unrelated accounts. This affects: customer registration, storefront login,
/// the re-resolution of the authenticated customer from the auth cookie, password recovery and
/// the duplicate-checks performed by the Admin / Store-manager customer editors.
/// </para>
/// <para>
/// NOTE: customers are still stored in a single collection (they are discriminated by the
/// <c>StoreId</c> field — there is no separate collection per store). Uniqueness is enforced at
/// the application layer; the customer lookup index is the compound (Email + StoreId) index.
/// Changing this value on a live installation that already contains duplicate e-mails across
/// stores can make some accounts unreachable, so set it before going live.
/// </para>
/// </summary>
public bool RegisterCustomersPerStore { get; set; }
}
1 change: 1 addition & 0 deletions src/Core/Grand.Infrastructure/StartupBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
/// <summary>
/// Init Database
/// </summary>
private static void InitDatabase(IServiceCollection services, IConfiguration configuration)

Check warning on line 35 in src/Core/Grand.Infrastructure/StartupBase.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove this unused method parameter 'services'.
{
var connectionString = configuration[SettingsConstants.ConnectionStrings];
var providerString = configuration[SettingsConstants.ConnectionStringsProvider];
Expand Down Expand Up @@ -105,7 +105,7 @@
}


private static T StartupConfig<T>(this IServiceCollection services, IConfiguration configuration)

Check warning on line 108 in src/Core/Grand.Infrastructure/StartupBase.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Change return type to 'void'; not a single caller uses the returned value.
where T : class, new()
{
ArgumentNullException.ThrowIfNull(services);
Expand Down Expand Up @@ -167,7 +167,7 @@
/// <param name="services">Collection of service descriptors</param>
/// <param name="configuration">Configuration</param>
/// <param name="typeSearcher">Type searcher</param>
private static IMvcCoreBuilder RegisterApplication(IServiceCollection services, IConfiguration configuration, IWebHostEnvironment hostingEnvironment, ITypeSearcher typeSearcher)

Check warning on line 170 in src/Core/Grand.Infrastructure/StartupBase.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove this unused method parameter 'typeSearcher'.
{
//add accessor to HttpContext
services.AddHttpContextAccessor();
Expand Down Expand Up @@ -214,6 +214,7 @@
});

services.StartupConfig<AppConfig>(configuration.GetSection("Application"));
services.StartupConfig<CustomerConfig>(configuration.GetSection("Customer"));
services.StartupConfig<PerformanceConfig>(configuration.GetSection("Performance"));
services.StartupConfig<SecurityConfig>(configuration.GetSection("Security"));
services.StartupConfig<ExtensionsConfig>(configuration.GetSection("Extensions"));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
using Grand.Business.Core.Interfaces.Customers;
using Grand.Infrastructure.Configuration;
using Grand.Module.Api.Commands.Models.Customers;
using Grand.SharedKernel;
using MediatR;

namespace Grand.Module.Api.Commands.Handlers.Customers;

public class DeleteCustomerCommandHandler : IRequestHandler<DeleteCustomerCommand, bool>
{
private readonly ICustomerService _customerService;
private readonly CustomerConfig _customerConfig;

public DeleteCustomerCommandHandler(ICustomerService customerService)
public DeleteCustomerCommandHandler(ICustomerService customerService, CustomerConfig customerConfig)
{
_customerService = customerService;
_customerConfig = customerConfig;
}

public async Task<bool> Handle(DeleteCustomerCommand request, CancellationToken cancellationToken)
{
var customer = await _customerService.GetCustomerByEmail(request.Email);
if (customer != null) await _customerService.DeleteCustomer(customer);
if (customer == null) return true;

//Under per-store customer identity the e-mail is not a unique key, and the global lookup prefers
//the store-independent (system/back-office/admin) account. Refuse to delete such an account through
//this by-email API so an attempt to remove a store customer can never destroy the administrator.
if (_customerConfig.RegisterCustomersPerStore && string.IsNullOrEmpty(customer.StoreId))
throw new GrandException(
"Refusing to delete a store-independent (system/back-office) account by e-mail while per-store customer identity is enabled");

await _customerService.DeleteCustomer(customer);

return true;
}
Expand Down
Loading
Loading