Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
cdc8cdb
Potential fix for pull request finding 'Useless assignment to local v…
KrzysztofPajak Jun 22, 2026
5f5d864
Update comments
KrzysztofPajak Jun 22, 2026
2b726ce
Add Grand.Module.Api.Tests: DeleteCustomerCommandHandler + LoginWebVa…
KrzysztofPajak Jun 22, 2026
83f723b
Add Grand.Web.Tests: storefront validators + sub-account handler
KrzysztofPajak Jun 22, 2026
2ee643c
Add TokenWebController tests (Frontend API per-store login/refresh)
KrzysztofPajak Jun 22, 2026
c16febb
Clarify store-scoped lookup fallback in GetCustomerByEmail/Username
KrzysztofPajak Jun 22, 2026
40588c3
Require customer StoreId only for non-admin editors
KrzysztofPajak Jun 23, 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
30 changes: 30 additions & 0 deletions GrandNode.sln
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{8D626EA8-C
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{03997797-E7F5-0643-168D-B8EA7178C2FE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Grand.Module.Api.Tests", "src\Tests\Grand.Module.Api.Tests\Grand.Module.Api.Tests.csproj", "{FE75CD3C-8329-C22D-A70E-B797DC30326D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Grand.Web.Tests", "src\Tests\Grand.Web.Tests\Grand.Web.Tests.csproj", "{920E338E-0E4A-4632-8D88-C89F340EB8A2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -913,6 +917,30 @@ Global
{5819B37B-4972-4BBC-B51C-57B0028EF869}.Release|x64.Build.0 = Release|Any CPU
{5819B37B-4972-4BBC-B51C-57B0028EF869}.Release|x86.ActiveCfg = Release|Any CPU
{5819B37B-4972-4BBC-B51C-57B0028EF869}.Release|x86.Build.0 = Release|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Debug|x64.ActiveCfg = Debug|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Debug|x64.Build.0 = Debug|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Debug|x86.ActiveCfg = Debug|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Debug|x86.Build.0 = Debug|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Release|Any CPU.Build.0 = Release|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Release|x64.ActiveCfg = Release|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Release|x64.Build.0 = Release|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Release|x86.ActiveCfg = Release|Any CPU
{FE75CD3C-8329-C22D-A70E-B797DC30326D}.Release|x86.Build.0 = Release|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Debug|x64.ActiveCfg = Debug|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Debug|x64.Build.0 = Debug|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Debug|x86.ActiveCfg = Debug|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Debug|x86.Build.0 = Debug|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Release|Any CPU.Build.0 = Release|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Release|x64.ActiveCfg = Release|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Release|x64.Build.0 = Release|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Release|x86.ActiveCfg = Release|Any CPU
{920E338E-0E4A-4632-8D88-C89F340EB8A2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -982,6 +1010,8 @@ Global
{5819B37B-4972-4BBC-B51C-57B0028EF869} = {CEA09484-30F6-4D44-02F6-822E06DBC57C}
{8D626EA8-CB54-BC41-363A-217881BEBA6E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{03997797-E7F5-0643-168D-B8EA7178C2FE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{FE75CD3C-8329-C22D-A70E-B797DC30326D} = {6360202A-F931-4BBD-ADBD-C9A628EE59F8}
{920E338E-0E4A-4632-8D88-C89F340EB8A2} = {CEA09484-30F6-4D44-02F6-822E06DBC57C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {88B478F4-FD3B-4C24-9E84-4FAAF0254397}
Expand Down
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 @@ public CookieAuthenticationService(

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 Down Expand Up @@ -84,6 +88,12 @@ public virtual async Task SignIn(Customer customer, bool isPersistent)
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 @@ public virtual async Task<Customer> GetAuthenticatedCustomer()

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 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;
var passwordToken = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Token")?.Value;
var refreshId = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "RefreshId")?.Value;
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;
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 @@ -104,11 +104,14 @@ Task<int> GetCountOnlineShoppingCart(DateTime lastActivityFromUtc, string storeI
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<int> GetCountOnlineShoppingCart(DateTime lastActivityFromUtc, string storeI
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
72 changes: 66 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 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 @@
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,40 @@
/// </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)
return inStore;

//no customer for this store: without per-store identity the e-mail is store-exact (no fallback);

Check warning on line 243 in src/Business/Grand.Business.Customers/Services/CustomerService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=grandnode_grandnode2&issues=AZ716NIc-rx95M4ui3FX&open=AZ716NIc-rx95M4ui3FX&pullRequest=717
//with per-store identity a store-scoped lookup (e.g. storefront login) must still reach the
//store-independent system/back-office account (administrator, created without a store)
if (!_customerConfig.RegisterCustomersPerStore)
return null;

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 +284,37 @@
/// </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)
return inStore;

//no customer for this store: without per-store identity there is no fallback; with it, a
//store-scoped lookup must still reach the store-independent system/back-office account
if (!_customerConfig.RegisterCustomersPerStore)
return null;

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