From 1c123a450e160b73a8f2cbfd5131639af4a4e6af Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 22:09:51 +0200 Subject: [PATCH 1/9] OpenID Connect: provider-only sign-in mode and break-glass accounts in the sign-in policy --- .../Services/OidcSignInModeCheck.cs | 25 +++++ .../Services/OidcSsoSignInPolicy.cs | 62 +++++++++-- .../Startup/StartupApplication.cs | 11 +- .../Authentication/IExternalSignInPolicy.cs | 4 + .../Authentication/SignInSurface.cs | 11 ++ .../Authentication/PasswordSignInDecision.cs | 13 +++ .../Configuration/OidcLoginConfig.cs | 27 +++++ .../Services/OidcLoginConfigTests.cs | 29 +++++ .../Services/OidcSignInModeCheckTests.cs | 37 +++++++ .../Services/OidcSsoSignInPolicyTests.cs | 101 +++++++++++++++++- src/Web/Grand.Web/App_Data/appsettings.json | 6 +- 11 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 src/Business/Grand.Business.Authentication/Services/OidcSignInModeCheck.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Authentication/SignInSurface.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Authentication/PasswordSignInDecision.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/OidcSignInModeCheckTests.cs diff --git a/src/Business/Grand.Business.Authentication/Services/OidcSignInModeCheck.cs b/src/Business/Grand.Business.Authentication/Services/OidcSignInModeCheck.cs new file mode 100644 index 000000000..d6c25d596 --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/OidcSignInModeCheck.cs @@ -0,0 +1,25 @@ +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Plugins; + +namespace Grand.Business.Authentication.Services; + +/// +/// Startup check of OidcLogin:SignInMode = ProviderOnly; whether the method is active is known per store only, so the +/// sign-in policy checks that on every refusal +/// +public static class OidcSignInModeCheck +{ + public static string Problem(OidcLoginConfig config, OidcResourceServerConfig resourceServer, + IEnumerable plugins) + { + if (config is not { ProviderOnly: true }) + return null; + if (!config.IsConfigured(resourceServer)) + return "OidcResourceServer:Authority or OidcLogin:ClientId is empty"; + if (plugins?.Any(x => x.Installed && + string.Equals(x.SystemName, OidcCustomerResolver.ProviderSystemName, + StringComparison.OrdinalIgnoreCase)) != true) + return "the OpenID Connect login plugin is not installed"; + return null; + } +} diff --git a/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs index 5d9469196..4cf61541f 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs @@ -1,8 +1,10 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; using Grand.Data; using Grand.Domain.Customers; using Grand.Infrastructure.Configuration; +using Microsoft.Extensions.Logging; namespace Grand.Business.Authentication.Services; @@ -10,23 +12,27 @@ namespace Grand.Business.Authentication.Services; /// OidcLogin:SsoOnly - an account connected to the identity provider with its password (a link not made by e-mail) /// signs in only through the provider, while the OpenID Connect login is installed, active and configured; a user /// disabled at the provider then cannot use a GrandNode password either. Accounts without such a link keep the -/// password: the break-glass account, and accounts still to be connected +/// password: the break-glass account, and accounts still to be connected. +/// OidcLogin:SignInMode = ProviderOnly - every account signs in through the provider; the password stays only for +/// break-glass accounts on the Admin panel, and a provider that is not available refuses the sign-in instead of +/// re-opening the password /// public class OidcSsoSignInPolicy( IRepository externalAuthenticationRepository, IEnumerable externalAuthenticationProviders, ExternalAuthenticationSettings externalAuthenticationSettings, OidcLoginConfig config, - OidcResourceServerConfig resourceServerConfig) : IExternalSignInPolicy + OidcResourceServerConfig resourceServerConfig, + ILogger logger) : IExternalSignInPolicy { + private static long _lastUnavailableLogTicks; + public async Task RequiredProvider(Customer customer) { - if (customer == null || !config.SsoOnly || !config.IsConfigured(resourceServerConfig)) + if (customer == null || !config.SsoOnly) return null; - - var provider = externalAuthenticationProviders.FirstOrDefault(x => - x.SystemName.Equals(OidcCustomerResolver.ProviderSystemName, StringComparison.OrdinalIgnoreCase)); - if (provider == null || !provider.IsMethodActive(externalAuthenticationSettings)) + var provider = AvailableProvider(); + if (provider == null) return null; var confirmed = await externalAuthenticationRepository.GetOneAsync(x => @@ -34,4 +40,46 @@ public async Task RequiredProvider(Customer cus !x.LinkedByEmail); return confirmed != null ? provider : null; } + + public async Task PasswordSignIn(Customer customer, SignInSurface surface) + { + if (customer == null) + return PasswordSignInDecision.Password; + + if (!config.ProviderOnly) + { + var required = await RequiredProvider(customer); + return required == null ? PasswordSignInDecision.Password : new PasswordSignInDecision(false, required); + } + + //break-glass: the password, on the Admin panel only, even when the provider is not available + if (surface == SignInSurface.AdminPanel && config.IsBreakGlass(customer.Email)) + return new PasswordSignInDecision(true, null, true); + + var provider = AvailableProvider(); + if (provider == null) + LogUnavailable(); + return new PasswordSignInDecision(false, provider); + } + + private IExternalAuthenticationProvider AvailableProvider() + { + if (!config.IsConfigured(resourceServerConfig)) + return null; + var provider = externalAuthenticationProviders.FirstOrDefault(x => + x.SystemName.Equals(OidcCustomerResolver.ProviderSystemName, StringComparison.OrdinalIgnoreCase)); + return provider != null && provider.IsMethodActive(externalAuthenticationSettings) ? provider : null; + } + + //every refused request would log it otherwise (plain cookies are checked on each request) + private void LogUnavailable() + { + var now = DateTime.UtcNow.Ticks; + var last = Interlocked.Read(ref _lastUnavailableLogTicks); + if (now - last < TimeSpan.TicksPerMinute * 5 || + Interlocked.CompareExchange(ref _lastUnavailableLogTicks, now, last) != last) + return; + logger.LogError("ProviderOnly sign-in mode without a working OpenID Connect login (plugin not installed, " + + "not active or not configured): only break-glass accounts can sign in"); + } } diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs index b4712bc78..60990f696 100644 --- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs @@ -1,10 +1,13 @@ using Grand.Business.Authentication.Services; using Grand.Business.Core.Interfaces.Authentication; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Plugins; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; #pragma warning disable CS0618 //API users stay supported until removed @@ -29,7 +32,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) - { + { + var problem = OidcSignInModeCheck.Problem(application.Services.GetService(), + application.Services.GetService(), PluginManager.ReferencedPlugins); + if (problem != null) + application.Services.GetRequiredService>().LogError( + "ProviderOnly sign-in mode without a working OpenID Connect login ({Problem}): only break-glass accounts can sign in", + problem); } public int Priority => 100; diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs index 277ee07be..b9835d983 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Domain.Customers; namespace Grand.Business.Core.Interfaces.Authentication; @@ -9,4 +10,7 @@ public interface IExternalSignInPolicy { /// The provider the account must sign in with; null when its password may be used Task RequiredProvider(Customer customer); + + /// Whether the account may sign in (or recover its password) with its GrandNode password on the surface + Task PasswordSignIn(Customer customer, SignInSurface surface); } diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/SignInSurface.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/SignInSurface.cs new file mode 100644 index 000000000..d2a1c9430 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/SignInSurface.cs @@ -0,0 +1,11 @@ +namespace Grand.Business.Core.Interfaces.Authentication; + +/// Where a password sign-in (or recovery) is attempted; break-glass accounts keep the password only on the Admin panel +public enum SignInSurface +{ + Storefront, + AdminPanel, + StorePanel, + VendorPanel, + FrontendApi +} diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/PasswordSignInDecision.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/PasswordSignInDecision.cs new file mode 100644 index 000000000..05439c3b9 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/PasswordSignInDecision.cs @@ -0,0 +1,13 @@ +using Grand.Business.Core.Interfaces.Authentication; + +namespace Grand.Business.Core.Utilities.Authentication; + +/// +/// Whether an account may use its GrandNode password; when not, the provider it must sign in with (null when that provider +/// is not available: sign-in is refused, never re-opened to the password). BreakGlass marks a password allowed only as the +/// emergency access of a provider-only installation +/// +public sealed record PasswordSignInDecision(bool Allowed, IExternalAuthenticationProvider Provider, bool BreakGlass = false) +{ + public static readonly PasswordSignInDecision Password = new(true, null); +} diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs index 26fc30576..a1dbe49fc 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs @@ -1,5 +1,12 @@ namespace Grand.Infrastructure.Configuration; +/// How accounts sign in: Mixed (passwords and providers, stage B rules) or ProviderOnly (the identity provider only) +public enum OidcSignInMode +{ + Mixed, + ProviderOnly +} + /// /// appsettings section OidcLogin: the client of this store at the identity provider configured in OidcResourceServer, /// and the policy for sessions started by a provider sign-in @@ -33,6 +40,26 @@ public class OidcLoginConfig /// public bool SsoOnly { get; set; } + /// + /// ProviderOnly: the identity provider is the only way to get an account and to sign in; GrandNode passwords work only for + /// on the Admin login page. Implies for every account + /// + public OidcSignInMode SignInMode { get; set; } = OidcSignInMode.Mixed; + + /// E-mails of accounts that keep password sign-in and recovery on the Admin login page in ProviderOnly + public string[] BreakGlassAccounts { get; set; } = []; + + public bool ProviderOnly => SignInMode == OidcSignInMode.ProviderOnly; + + public bool IsBreakGlass(string email) + { + if (string.IsNullOrWhiteSpace(email) || BreakGlassAccounts == null) + return false; + var trimmed = email.Trim(); + return BreakGlassAccounts.Any(x => + !string.IsNullOrWhiteSpace(x) && string.Equals(x.Trim(), trimmed, StringComparison.OrdinalIgnoreCase)); + } + //zero or less is a configuration mistake, never "check on every request" or "end every session at once" public TimeSpan StaffIdleTimeout => TimeSpan.FromMinutes(StaffIdleTimeoutMinutes > 0 ? StaffIdleTimeoutMinutes : DefaultStaffIdleTimeoutMinutes); diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs index 11701dc86..41f8111f0 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs @@ -38,4 +38,33 @@ public void NonPositiveValues_FallBackToDefaults(int value) public void IsConfigured(string authority, string clientId, bool expected) => Assert.AreEqual(expected, new OidcLoginConfig { ClientId = clientId } .IsConfigured(new OidcResourceServerConfig { Authority = authority })); + + [TestMethod] + public void Defaults_MixedWithoutBreakGlass() + { + var config = new OidcLoginConfig(); + Assert.AreEqual(OidcSignInMode.Mixed, config.SignInMode); + Assert.IsFalse(config.ProviderOnly); + Assert.AreEqual(0, config.BreakGlassAccounts.Length); + } + + [TestMethod] + [DataRow("admin@shop.com", true)] + [DataRow(" Admin@Shop.COM ", true)] + [DataRow("other@shop.com", false)] + [DataRow("", false)] + [DataRow(null, false)] + public void IsBreakGlass_TrimmedCaseInsensitive(string email, bool expected) + { + var config = new OidcLoginConfig { BreakGlassAccounts = [" ADMIN@shop.com", "", null] }; + Assert.AreEqual(expected, config.IsBreakGlass(email)); + } + + //an empty entry in appsettings must never make an account without e-mail a break-glass account + [TestMethod] + public void IsBreakGlass_NullList_False() + { + var config = new OidcLoginConfig { BreakGlassAccounts = null }; + Assert.IsFalse(config.IsBreakGlass("admin@shop.com")); + } } diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSignInModeCheckTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSignInModeCheckTests.cs new file mode 100644 index 000000000..79f7d5eeb --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSignInModeCheckTests.cs @@ -0,0 +1,37 @@ +using Grand.Business.Authentication.Services; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Plugins; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcSignInModeCheckTests +{ + private readonly OidcResourceServerConfig _resourceServer = new() { Authority = "https://idp" }; + + private static PluginInfo[] Plugin(bool installed) => + [new PluginInfo { SystemName = OidcCustomerResolver.ProviderSystemName, Installed = installed }]; + + [TestMethod] + public void Mixed_NeverAProblem() => + Assert.IsNull(OidcSignInModeCheck.Problem(new OidcLoginConfig(), new OidcResourceServerConfig(), [])); + + [TestMethod] + public void ProviderOnly_Working_NoProblem() => + Assert.IsNull(OidcSignInModeCheck.Problem( + new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly, ClientId = "shop" }, _resourceServer, Plugin(true))); + + [TestMethod] + public void ProviderOnly_NotConfigured_Problem() => + Assert.IsNotNull(OidcSignInModeCheck.Problem( + new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }, _resourceServer, Plugin(true))); + + [TestMethod] + public void ProviderOnly_PluginNotInstalled_Problem() + { + var config = new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly, ClientId = "shop" }; + Assert.IsNotNull(OidcSignInModeCheck.Problem(config, _resourceServer, Plugin(false))); + Assert.IsNotNull(OidcSignInModeCheck.Problem(config, _resourceServer, null)); + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs index 406d95cd3..199a15596 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs @@ -4,6 +4,7 @@ using Grand.Data.Tests.MongoDb; using Grand.Domain.Customers; using Grand.Infrastructure.Configuration; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -32,7 +33,8 @@ public void Init() } private OidcSsoSignInPolicy Policy(params IExternalAuthenticationProvider[] providers) => - new(_links, providers.Length > 0 ? providers : [_provider.Object], _settings, _config, _resourceServer); + new(_links, providers.Length > 0 ? providers : [_provider.Object], _settings, _config, _resourceServer, + NullLogger.Instance); private Task Link(bool byEmail) => _links.InsertAsync(new ExternalAuthentication { CustomerId = "c1", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", @@ -81,8 +83,8 @@ public async Task MethodInactive_PasswordAllowed() public async Task PluginNotInstalled_PasswordAllowed() { await Link(byEmail: false); - Assert.IsNull(await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer) - .RequiredProvider(_customer)); + Assert.IsNull(await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, + NullLogger.Instance).RequiredProvider(_customer)); } [TestMethod] @@ -92,4 +94,97 @@ public async Task NotConfigured_PasswordAllowed() await Link(byEmail: false); Assert.IsNull(await Policy().RequiredProvider(_customer)); } + + private void ProviderOnly(params string[] breakGlass) + { + _config.SignInMode = OidcSignInMode.ProviderOnly; + _config.BreakGlassAccounts = breakGlass; + } + + //Mixed keeps the stage B answer whatever the surface + [TestMethod] + [DataRow(SignInSurface.Storefront)] + [DataRow(SignInSurface.AdminPanel)] + public async Task Mixed_PasswordSignIn_MatchesStageB(SignInSurface surface) + { + Assert.IsTrue((await Policy().PasswordSignIn(_customer, surface)).Allowed); + await Link(byEmail: false); + var decision = await Policy().PasswordSignIn(_customer, surface); + Assert.IsFalse(decision.Allowed); + Assert.AreSame(_provider.Object, decision.Provider); + } + + //every account, connected or not + [TestMethod] + [DataRow(SignInSurface.Storefront)] + [DataRow(SignInSurface.AdminPanel)] + [DataRow(SignInSurface.StorePanel)] + [DataRow(SignInSurface.VendorPanel)] + [DataRow(SignInSurface.FrontendApi)] + public async Task ProviderOnly_NoLink_ProviderRequired(SignInSurface surface) + { + ProviderOnly(); + _customer.Email = "user@shop.com"; + var decision = await Policy().PasswordSignIn(_customer, surface); + Assert.IsFalse(decision.Allowed); + Assert.AreSame(_provider.Object, decision.Provider); + } + + [TestMethod] + public async Task ProviderOnly_BreakGlass_AdminPanel_PasswordAllowed() + { + ProviderOnly("admin@shop.com"); + _customer.Email = "Admin@Shop.com"; + var decision = await Policy().PasswordSignIn(_customer, SignInSurface.AdminPanel); + Assert.IsTrue(decision.Allowed); + Assert.IsTrue(decision.BreakGlass); + } + + [TestMethod] + [DataRow(SignInSurface.Storefront)] + [DataRow(SignInSurface.StorePanel)] + [DataRow(SignInSurface.VendorPanel)] + [DataRow(SignInSurface.FrontendApi)] + public async Task ProviderOnly_BreakGlass_OtherSurface_ProviderRequired(SignInSurface surface) + { + ProviderOnly("admin@shop.com"); + _customer.Email = "admin@shop.com"; + Assert.IsFalse((await Policy().PasswordSignIn(_customer, surface)).Allowed); + } + + //fail closed: no working provider never re-opens the password + [TestMethod] + public async Task ProviderOnly_PluginMissing_RefusedWithoutProvider() + { + ProviderOnly(); + var decision = await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, + NullLogger.Instance).PasswordSignIn(_customer, SignInSurface.Storefront); + Assert.IsFalse(decision.Allowed); + Assert.IsNull(decision.Provider); + } + + [TestMethod] + public async Task ProviderOnly_MethodInactive_RefusedWithoutProvider() + { + ProviderOnly(); + _settings.ActiveAuthenticationMethodSystemNames = []; + var decision = await Policy().PasswordSignIn(_customer, SignInSurface.Storefront); + Assert.IsFalse(decision.Allowed); + Assert.IsNull(decision.Provider); + } + + [TestMethod] + public async Task ProviderOnly_NotConfigured_BreakGlassStillAllowedOnAdmin() + { + ProviderOnly("admin@shop.com"); + _config.ClientId = ""; + _customer.Email = "admin@shop.com"; + Assert.IsTrue((await Policy().PasswordSignIn(_customer, SignInSurface.AdminPanel)).Allowed); + _customer.Email = "user@shop.com"; + Assert.IsFalse((await Policy().PasswordSignIn(_customer, SignInSurface.AdminPanel)).Allowed); + } + + [TestMethod] + public async Task NullCustomer_PasswordAllowed() => + Assert.IsTrue((await Policy().PasswordSignIn(null, SignInSurface.Storefront)).Allowed); } diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index c099a0e85..9da6f17dc 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -288,7 +288,11 @@ "StaffIdleTimeoutMinutes": 30, "StaffMaxSessionHours": 10, //true: accounts connected to the provider (with their password once) sign in only through it; accounts without a connection keep the password - "SsoOnly": false + "SsoOnly": false, + //Mixed (default) or ProviderOnly: the identity provider is the only way to get an account and to sign in (storefront, panels, APIs) + "SignInMode": "Mixed", + //ProviderOnly: e-mails that keep the password on the Admin login page (/admin/login?password=1); every such sign-in is logged + "BreakGlassAccounts": [] }, "Logging": { "LogLevel": { From b27879f541d64992c9ecec0d960e97969b45ae19 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 22:15:11 +0200 Subject: [PATCH 2/9] OpenID Connect: throttle the provider-unavailable log through IMemoryCache --- .../Services/OidcSsoSignInPolicy.cs | 10 +++---- .../Services/OidcSsoSignInPolicyTests.cs | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs index 4cf61541f..6c1e7ed6a 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs @@ -4,6 +4,7 @@ using Grand.Data; using Grand.Domain.Customers; using Grand.Infrastructure.Configuration; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Grand.Business.Authentication.Services; @@ -23,9 +24,10 @@ public class OidcSsoSignInPolicy( ExternalAuthenticationSettings externalAuthenticationSettings, OidcLoginConfig config, OidcResourceServerConfig resourceServerConfig, + IMemoryCache memoryCache, ILogger logger) : IExternalSignInPolicy { - private static long _lastUnavailableLogTicks; + private const string UnavailableLogKey = "Grand.OidcSignInMode.Unavailable"; public async Task RequiredProvider(Customer customer) { @@ -74,11 +76,9 @@ private IExternalAuthenticationProvider AvailableProvider() //every refused request would log it otherwise (plain cookies are checked on each request) private void LogUnavailable() { - var now = DateTime.UtcNow.Ticks; - var last = Interlocked.Read(ref _lastUnavailableLogTicks); - if (now - last < TimeSpan.TicksPerMinute * 5 || - Interlocked.CompareExchange(ref _lastUnavailableLogTicks, now, last) != last) + if (memoryCache.TryGetValue(UnavailableLogKey, out _)) return; + memoryCache.Set(UnavailableLogKey, true, TimeSpan.FromMinutes(5)); logger.LogError("ProviderOnly sign-in mode without a working OpenID Connect login (plugin not installed, " + "not active or not configured): only break-glass accounts can sign in"); } diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs index 199a15596..c3aab5120 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs @@ -4,6 +4,8 @@ using Grand.Data.Tests.MongoDb; using Grand.Domain.Customers; using Grand.Infrastructure.Configuration; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -18,12 +20,14 @@ public class OidcSsoSignInPolicyTests private ExternalAuthenticationSettings _settings; private OidcLoginConfig _config; private OidcResourceServerConfig _resourceServer; + private IMemoryCache _cache; private readonly Customer _customer = new() { Id = "c1" }; [TestInitialize] public void Init() { _links = new MongoDBRepositoryTest(); + _cache = new MemoryCache(new MemoryCacheOptions()); _provider = new Mock(); _provider.Setup(x => x.SystemName).Returns(OidcCustomerResolver.ProviderSystemName); _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); @@ -34,7 +38,7 @@ public void Init() private OidcSsoSignInPolicy Policy(params IExternalAuthenticationProvider[] providers) => new(_links, providers.Length > 0 ? providers : [_provider.Object], _settings, _config, _resourceServer, - NullLogger.Instance); + _cache, NullLogger.Instance); private Task Link(bool byEmail) => _links.InsertAsync(new ExternalAuthentication { CustomerId = "c1", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", @@ -84,7 +88,7 @@ public async Task PluginNotInstalled_PasswordAllowed() { await Link(byEmail: false); Assert.IsNull(await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, - NullLogger.Instance).RequiredProvider(_customer)); + _cache, NullLogger.Instance).RequiredProvider(_customer)); } [TestMethod] @@ -149,7 +153,9 @@ public async Task ProviderOnly_BreakGlass_OtherSurface_ProviderRequired(SignInSu { ProviderOnly("admin@shop.com"); _customer.Email = "admin@shop.com"; - Assert.IsFalse((await Policy().PasswordSignIn(_customer, surface)).Allowed); + var decision = await Policy().PasswordSignIn(_customer, surface); + Assert.IsFalse(decision.Allowed); + Assert.AreSame(_provider.Object, decision.Provider); } //fail closed: no working provider never re-opens the password @@ -158,11 +164,24 @@ public async Task ProviderOnly_PluginMissing_RefusedWithoutProvider() { ProviderOnly(); var decision = await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, - NullLogger.Instance).PasswordSignIn(_customer, SignInSurface.Storefront); + _cache, NullLogger.Instance).PasswordSignIn(_customer, SignInSurface.Storefront); Assert.IsFalse(decision.Allowed); Assert.IsNull(decision.Provider); } + //every refused request would log it otherwise + [TestMethod] + public async Task ProviderOnly_PluginMissing_LoggedOncePerWindow() + { + ProviderOnly(); + var logger = new Mock>(); + var policy = new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, _cache, logger.Object); + await policy.PasswordSignIn(_customer, SignInSurface.Storefront); + await policy.PasswordSignIn(_customer, SignInSurface.Storefront); + logger.Verify(x => x.Log(LogLevel.Error, It.IsAny(), It.IsAny(), null, + It.IsAny>()), Times.Once); + } + [TestMethod] public async Task ProviderOnly_MethodInactive_RefusedWithoutProvider() { From 809000f5c7beb4bbab3316cbd93816af244c7c49 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 22:26:20 +0200 Subject: [PATCH 3/9] Sign-in policy decides per surface: storefront, panels and APIs ask it with where the password is used --- .../Services/CookieAuthenticationService.cs | 5 +- .../Services/OidcSsoSignInPolicy.cs | 2 +- .../Authentication/IExternalSignInPolicy.cs | 3 - .../Customers/ICustomerManagerService.cs | 5 +- .../Services/CustomerManagerService.cs | 17 +++++- .../Controllers/TokenWebController.cs | 2 +- .../Validators/Common/LoginValidator.cs | 7 ++- .../Validators/Common/LoginWebValidator.cs | 4 +- .../CookieAuthenticationServiceTests.cs | 34 ++++++++--- .../Services/OidcSsoSignInPolicyTests.cs | 18 +++--- .../Services/CustomerManagerServiceTests.cs | 33 +++++++++-- .../Controllers/TokenWebControllerTests.cs | 9 ++- .../Common/LoginValidatorProviderOnlyTests.cs | 56 ++++++++++++++++++ .../Common/LoginWebValidatorTests.cs | 11 ++-- .../Extensions/PanelSignInSurfaceTests.cs | 19 ++++++ .../PanelPasswordRecoveryServiceTests.cs | 9 +-- .../LoginValidatorExternalSignInTests.cs | 33 +++++++++-- ...PasswordRecoverySendCommandHandlerTests.cs | 13 +++- ...ccountControllerTwoFactorReturnUrlTests.cs | 2 +- .../LoginValidatorExternalSignInTests.cs | 7 ++- ...veryConfirmValidatorExternalSignInTests.cs | 6 +- .../Controllers/BaseLoginController.cs | 4 +- .../Extensions/PanelSignInSurface.cs | 14 +++++ .../Services/PanelPasswordRecoveryService.cs | 3 +- .../Validators/Common/LoginValidator.cs | 13 ++-- .../App_Data/Resources/DefaultLanguage.xml | Bin 1568472 -> 1568934 bytes .../App_Data/Resources/Upgrade/en_250.xml | 3 + .../PasswordRecoverySendCommandHandler.cs | 2 +- .../Controllers/AccountController.cs | 3 +- .../Validators/Customer/LoginValidator.cs | 10 ++-- .../PasswordRecoveryConfirmValidator.cs | 2 +- 31 files changed, 280 insertions(+), 69 deletions(-) create mode 100644 src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Extensions/PanelSignInSurfaceTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Extensions/PanelSignInSurface.cs diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs index 96132773b..f200fb988 100644 --- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs @@ -283,8 +283,9 @@ private async Task IsValidCustomer(Customer customer, ClaimsPrincipal prin return await _oidcSessionService.Validate(oidcSessionId, customer); //a cookie without a session (issued before the account was connected or before the provider became required) - //would escape revalidation and back-channel logout: an account that must use the provider signs in again there - return await _externalSignInPolicy.RequiredProvider(customer) == null; + //would escape revalidation and back-channel logout: an account that must use the provider signs in again there; + //in ProviderOnly only a break-glass account (password on the Admin panel) keeps a plain cookie + return (await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.AdminPanel)).Allowed; } private async Task EndOidcSession(string sessionId) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs index 6c1e7ed6a..8c6b94d83 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs @@ -29,7 +29,7 @@ public class OidcSsoSignInPolicy( { private const string UnavailableLogKey = "Grand.OidcSignInMode.Unavailable"; - public async Task RequiredProvider(Customer customer) + private async Task RequiredProvider(Customer customer) { if (customer == null || !config.SsoOnly) return null; diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs index b9835d983..d9d320c51 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs @@ -8,9 +8,6 @@ namespace Grand.Business.Core.Interfaces.Authentication; /// public interface IExternalSignInPolicy { - /// The provider the account must sign in with; null when its password may be used - Task RequiredProvider(Customer customer); - /// Whether the account may sign in (or recover its password) with its GrandNode password on the surface Task PasswordSignIn(Customer customer, SignInSurface surface); } diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs index 9536c9197..bdacc0466 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Customers; @@ -14,8 +15,10 @@ public interface ICustomerManagerService /// Username or email /// Password /// Store identifier - used to resolve the customer when per-store customer identity is enabled + /// Where the password is used: the sign-in policy decides per surface /// Result - Task LoginCustomer(string usernameOrEmail, string password, string storeId = ""); + Task LoginCustomer(string usernameOrEmail, string password, string storeId = "", + SignInSurface surface = SignInSurface.Storefront); /// /// Register customer diff --git a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs index 46722c0f4..604b8fe92 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs @@ -9,6 +9,7 @@ using Grand.SharedKernel; using Grand.SharedKernel.Extensions; using Grand.Mediator; +using Microsoft.Extensions.Logging; namespace Grand.Business.Customers.Services; @@ -29,6 +30,7 @@ public class CustomerManagerService : ICustomerManagerService /// History password /// Customer settings /// Accounts that must sign in with an external provider + /// Logger public CustomerManagerService( ICustomerService customerService, IGroupService groupService, @@ -36,8 +38,10 @@ public CustomerManagerService( IMediator mediator, ICustomerHistoryPasswordService customerHistoryPasswordService, CustomerSettings customerSettings, - IExternalSignInPolicy externalSignInPolicy) + IExternalSignInPolicy externalSignInPolicy, + ILogger logger) { + _logger = logger; _externalSignInPolicy = externalSignInPolicy; _customerService = customerService; _groupService = groupService; @@ -58,6 +62,7 @@ public CustomerManagerService( private readonly ICustomerHistoryPasswordService _customerHistoryPasswordService; private readonly CustomerSettings _customerSettings; private readonly IExternalSignInPolicy _externalSignInPolicy; + private readonly ILogger _logger; #endregion @@ -77,8 +82,11 @@ public virtual bool PasswordMatch(PasswordFormat passwordFormat, string oldPassw /// /// Username or email /// Password + /// Store identifier - used to resolve the customer when per-store customer identity is enabled + /// Where the password is used: the sign-in policy decides per surface /// Result - public virtual async Task LoginCustomer(string usernameOrEmail, string password, string storeId = "") + public virtual async Task LoginCustomer(string usernameOrEmail, string password, + string storeId = "", SignInSurface surface = SignInSurface.Storefront) { var customer = _customerSettings.UsernamesEnabled ? await _customerService.GetCustomerByUsername(usernameOrEmail, storeId) @@ -90,8 +98,11 @@ public virtual async Task LoginCustomer(string usernameOrE return CustomerLoginResults.WrongPassword; //checked after the password, so the answer tells nothing to someone who does not know it - if (await _externalSignInPolicy.RequiredProvider(customer) != null) + var decision = await _externalSignInPolicy.PasswordSignIn(customer, surface); + if (!decision.Allowed) return CustomerLoginResults.ExternalSignInRequired; + if (decision.BreakGlass) + _logger.LogWarning("Break-glass password sign-in of customer {CustomerId} on the Admin panel", customer.Id); //transparently migrate weak/legacy hashes (SHA-x, Clear, Encrypted) to the modern PBKDF2 format if (_encryptionService.PasswordHashNeedsUpgrade(customer.PasswordFormatId, customer.Password)) diff --git a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs index 049cfa810..01e234e3b 100644 --- a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs +++ b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs @@ -148,7 +148,7 @@ public async Task Refresh([FromBody] TokenDto tokenDto) //tokens obtained with the password before the account had to sign in through the provider are not renewed: //the provider could not stop them for a user it disabled - if (await _externalSignInPolicy.RequiredProvider(customer) != null) + if (!(await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.FrontendApi)).Allowed) return BadRequest("Invalid refresh token"); //rebuild the claims from the resolved customer (registered carry id/email/token, guests carry the guid) diff --git a/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs b/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs index 674e784d8..2d1cb4f25 100644 --- a/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs +++ b/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs @@ -15,12 +15,17 @@ public class LoginValidator : BaseGrandValidator public LoginValidator( IEnumerable> validators, BackendAPIConfig apiConfig, ICustomerService customerService, IUserApiService userApiService, - IEncryptionService encryptionService) + IEncryptionService encryptionService, OidcLoginConfig oidcLoginConfig) : base(validators) { if (!apiConfig.Enabled) RuleFor(x => x).Must(_ => false).WithMessage("API is disabled"); + //provider-only: no password sign-in outside the Admin login page, API users included + if (oidcLoginConfig.ProviderOnly) + RuleFor(x => x).Must(_ => false) + .WithMessage("Sign-in with API users is disabled: use access tokens from the identity provider"); + RuleFor(x => x.Email).NotEmpty().WithMessage("Email is required"); RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); RuleFor(x => x).MustAsync(async (x, _) => diff --git a/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs b/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs index 29349e51d..f964f5570 100644 --- a/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs +++ b/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using Grand.Module.Api.Models.Common; +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; using Grand.Infrastructure; @@ -40,7 +41,8 @@ public LoginWebValidator( { var base64EncodedBytes = Convert.FromBase64String(x.Password); var password = Encoding.UTF8.GetString(base64EncodedBytes); - var result = await customerManagerService.LoginCustomer(x.Email, password, storeId); + var result = await customerManagerService.LoginCustomer(x.Email, password, storeId, + SignInSurface.FrontendApi); return result == CustomerLoginResults.Successful; } } diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs index f7d5a02f6..b5e9e289f 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs @@ -46,9 +46,9 @@ public void Init() _cookieOptionsFactory = new CookieOptionsFactory(_config); _oidcSessionService = new Mock(); _signInPolicy = new Mock(); - _cookieAuthService = new CookieAuthenticationService(_customerSettings, _customerServiceMock.Object, - _groupServiceMock.Object, _httpAccessorMock.Object, _cookieOptionsFactory, _config, - _oidcSessionService.Object, _signInPolicy.Object, NullLogger.Instance); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); + _cookieAuthService = NewService(); //For mock HttpContext extension methods like SignOutAsync ,SignInAsync etc.. _authServiceMock = new Mock(); serviceProviderMock = new Mock(); @@ -203,6 +203,10 @@ public async Task SignIn_WithOidcSession_AddsClaim() Assert.AreEqual("grandnode", claim.Issuer); } + private CookieAuthenticationService NewService() => new(_customerSettings, _customerServiceMock.Object, + _groupServiceMock.Object, _httpAccessorMock.Object, _cookieOptionsFactory, _config, + _oidcSessionService.Object, _signInPolicy.Object, NullLogger.Instance); + [TestMethod] public async Task GetAuthenticatedCustomer_NoSessionClaim_SessionServiceNotAsked() { @@ -219,7 +223,7 @@ public async Task GetAuthenticatedCustomer_NoSessionClaim_ProviderRequired_Null( { var customer = RegisteredCustomer(); _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Ticket()); - _signInPolicy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync(Mock.Of()); + _signInPolicy.Setup(x => x.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, Mock.Of())); Assert.IsNull(await _cookieAuthService.GetAuthenticatedCustomer()); } @@ -228,9 +232,25 @@ public async Task GetAuthenticatedCustomer_NoSessionClaim_PasswordAllowed_Return { var customer = RegisteredCustomer(); _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Ticket()); - _signInPolicy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync((IExternalAuthenticationProvider)null); + _signInPolicy.Setup(x => x.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(PasswordSignInDecision.Password); Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer()); - _signInPolicy.Verify(x => x.RequiredProvider(customer), Times.Once); + _signInPolicy.Verify(x => x.PasswordSignIn(customer, It.IsAny()), Times.Once); + } + + //a password cookie from before the switch stops at the next request; break-glass keeps its cookie everywhere + [TestMethod] + public async Task ProviderOnly_PlainCookie_RefusedUnlessPolicyAllowsAdminPanel() + { + RegisteredCustomer(); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Ticket()); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.AdminPanel)) + .ReturnsAsync(new PasswordSignInDecision(false, null)); + Assert.IsNull(await _cookieAuthService.GetAuthenticatedCustomer()); + + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.AdminPanel)) + .ReturnsAsync(new PasswordSignInDecision(true, null, true)); + //a new service instance: the authenticated customer is cached per instance + Assert.IsNotNull(await NewService().GetAuthenticatedCustomer()); } //a session cookie is decided by its session; the policy is not asked @@ -242,7 +262,7 @@ public async Task GetAuthenticatedCustomer_SessionClaim_PolicyNotAsked() .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); _oidcSessionService.Setup(x => x.Validate("session-1", customer)).ReturnsAsync(true); Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer()); - _signInPolicy.Verify(x => x.RequiredProvider(It.IsAny()), Times.Never); + _signInPolicy.Verify(x => x.PasswordSignIn(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs index c3aab5120..c65ea1946 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs @@ -52,19 +52,21 @@ private Task Link(bool byEmail) => _links.InsertAsync(new ExternalAuthentication public async Task ConfirmedLink_ProviderRequired_WhateverTheAccount(bool staff) { await Link(byEmail: false); - Assert.AreSame(_provider.Object, await Policy().RequiredProvider(_customer)); + var decision = await Policy().PasswordSignIn(_customer, SignInSurface.Storefront); + Assert.IsFalse(decision.Allowed); + Assert.AreSame(_provider.Object, decision.Provider); } //break-glass: an account never connected to the provider keeps its password [TestMethod] - public async Task WithoutLink_PasswordAllowed() => Assert.IsNull(await Policy().RequiredProvider(_customer)); + public async Task WithoutLink_PasswordAllowed() => Assert.IsTrue((await Policy().PasswordSignIn(_customer, SignInSurface.Storefront)).Allowed); //"connect your account" needs the password [TestMethod] public async Task LinkedByEmail_PasswordAllowed() { await Link(byEmail: true); - Assert.IsNull(await Policy().RequiredProvider(_customer)); + Assert.IsTrue((await Policy().PasswordSignIn(_customer, SignInSurface.Storefront)).Allowed); } [TestMethod] @@ -72,7 +74,7 @@ public async Task OptionOff_PasswordAllowed() { _config.SsoOnly = false; await Link(byEmail: false); - Assert.IsNull(await Policy().RequiredProvider(_customer)); + Assert.IsTrue((await Policy().PasswordSignIn(_customer, SignInSurface.Storefront)).Allowed); } [TestMethod] @@ -80,15 +82,15 @@ public async Task MethodInactive_PasswordAllowed() { _settings.ActiveAuthenticationMethodSystemNames = []; await Link(byEmail: false); - Assert.IsNull(await Policy().RequiredProvider(_customer)); + Assert.IsTrue((await Policy().PasswordSignIn(_customer, SignInSurface.Storefront)).Allowed); } [TestMethod] public async Task PluginNotInstalled_PasswordAllowed() { await Link(byEmail: false); - Assert.IsNull(await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, - _cache, NullLogger.Instance).RequiredProvider(_customer)); + Assert.IsTrue((await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer, + _cache, NullLogger.Instance).PasswordSignIn(_customer, SignInSurface.Storefront)).Allowed); } [TestMethod] @@ -96,7 +98,7 @@ public async Task NotConfigured_PasswordAllowed() { _config.ClientId = ""; await Link(byEmail: false); - Assert.IsNull(await Policy().RequiredProvider(_customer)); + Assert.IsTrue((await Policy().PasswordSignIn(_customer, SignInSurface.Storefront)).Allowed); } private void ProviderOnly(params string[] breakGlass) diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs index 7374fb55d..7c546c2ae 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs @@ -1,4 +1,5 @@ -using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Customers; @@ -6,6 +7,7 @@ using Grand.Business.Customers.Services; using Grand.Domain.Customers; using Grand.Mediator; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -38,13 +40,15 @@ public void Init() _mediatorMock = new Mock(); _customerHistoryPasswordServiceMock = new Mock(); _signInPolicy = new Mock(); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); _customerSettings = new CustomerSettings { AllowUsersToChangeUsernames = true }; _customerManagerService = new CustomerManagerService(_customerServiceMock.Object, _groupServiceMock.Object, _encryptionServiceMock.Object, _mediatorMock.Object, _customerHistoryPasswordServiceMock.Object, _customerSettings, - _signInPolicy.Object); + _signInPolicy.Object, NullLogger.Instance); } [TestMethod] @@ -52,7 +56,28 @@ public async Task LoginCustomer_ExternalSignInRequired_AfterCorrectPassword() { var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); - _signInPolicy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync(new Mock().Object); + _signInPolicy.Setup(x => x.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); + Assert.AreEqual(CustomerLoginResults.ExternalSignInRequired, + await _customerManagerService.LoginCustomer("admin@admin.com", "123456")); + } + + [TestMethod] + public async Task LoginCustomer_PassesSurfaceToPolicy() + { + var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; + _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + _groupServiceMock.Setup(c => c.IsRegistered(It.IsAny())).ReturnsAsync(true); + await _customerManagerService.LoginCustomer("admin@admin.com", "123456", "", SignInSurface.AdminPanel); + _signInPolicy.Verify(x => x.PasswordSignIn(It.IsAny(), SignInSurface.AdminPanel), Times.Once); + } + + [TestMethod] + public async Task LoginCustomer_RefusedByPolicy_ExternalSignInRequired() + { + var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; + _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PasswordSignInDecision(false, null)); Assert.AreEqual(CustomerLoginResults.ExternalSignInRequired, await _customerManagerService.LoginCustomer("admin@admin.com", "123456")); } @@ -64,7 +89,7 @@ public async Task LoginCustomer_WrongPassword_PolicyNotAsked() var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); Assert.AreEqual(CustomerLoginResults.WrongPassword, await _customerManagerService.LoginCustomer("a@b.c", "bad")); - _signInPolicy.Verify(x => x.RequiredProvider(It.IsAny()), Times.Never); + _signInPolicy.Verify(x => x.PasswordSignIn(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] diff --git a/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs b/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs index dad570554..4c778d5e3 100644 --- a/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; @@ -36,6 +37,8 @@ public void Init() _mediatorMock = new Mock(); _refreshTokenServiceMock = new Mock(); _signInPolicyMock = new Mock(); + _signInPolicyMock.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); _refreshTokenServiceMock.Setup(r => r.GenerateRefreshToken()).Returns("rt"); _refreshTokenServiceMock.Setup(r => r.SaveRefreshTokenToCustomer(It.IsAny(), It.IsAny())) @@ -122,7 +125,7 @@ private Customer RefreshableCustomer() public async Task Refresh_ProviderRequired_InvalidRefreshToken() { var customer = RefreshableCustomer(); - _signInPolicyMock.Setup(p => p.RequiredProvider(customer)).ReturnsAsync(Mock.Of()); + _signInPolicyMock.Setup(p => p.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, Mock.Of())); var controller = CreateController(perStore: false); var result = await controller.Refresh(new TokenDto { AccessToken = "access", RefreshToken = "rt" }); @@ -137,12 +140,12 @@ public async Task Refresh_ProviderRequired_InvalidRefreshToken() public async Task Refresh_PasswordAllowed_Ok() { var customer = RefreshableCustomer(); - _signInPolicyMock.Setup(p => p.RequiredProvider(customer)).ReturnsAsync((IExternalAuthenticationProvider)null); + _signInPolicyMock.Setup(p => p.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(PasswordSignInDecision.Password); var controller = CreateController(perStore: false); var result = await controller.Refresh(new TokenDto { AccessToken = "access", RefreshToken = "rt" }); Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - _signInPolicyMock.Verify(p => p.RequiredProvider(customer), Times.Once); + _signInPolicyMock.Verify(p => p.PasswordSignIn(customer, It.IsAny()), Times.Once); } } diff --git a/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs new file mode 100644 index 000000000..4342425cd --- /dev/null +++ b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs @@ -0,0 +1,56 @@ +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models.Common; +using Grand.Module.Api.Validators.Common; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Text; + +#pragma warning disable CS0618 //API users stay supported until removed + +namespace Grand.Module.Api.Tests.Validators.Common; + +[TestClass] +public class LoginValidatorProviderOnlyTests +{ + private const string Message = "Sign-in with API users is disabled: use access tokens from the identity provider"; + + private static LoginValidator CreateValidator(OidcSignInMode mode) + { + var customerService = new Mock(); + customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Active = true }); + var userApiService = new Mock(); + userApiService.Setup(x => x.GetUserByEmail(It.IsAny())) + .ReturnsAsync(new UserApi { Email = "user@x.com", Password = "hash", IsActive = true }); + var encryption = new Mock(); + encryption.Setup(x => x.IsHashedPassword(It.IsAny())).Returns(true); + encryption.Setup(x => x.VerifyPassword(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(true); + return new LoginValidator(new List>(), new BackendAPIConfig { Enabled = true }, + customerService.Object, userApiService.Object, encryption.Object, + new OidcLoginConfig { SignInMode = mode }); + } + + private static LoginModel ValidModel() => new() { + Email = "user@x.com", Password = Convert.ToBase64String(Encoding.UTF8.GetBytes("secret")) + }; + + [TestMethod] + public async Task ProviderOnly_ValidApiUser_Refused() + { + var result = await CreateValidator(OidcSignInMode.ProviderOnly).ValidateAsync(ValidModel()); + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == Message)); + } + + [TestMethod] + public async Task Mixed_ValidApiUser_Valid() + { + var result = await CreateValidator(OidcSignInMode.Mixed).ValidateAsync(ValidModel()); + Assert.IsTrue(result.IsValid); + } +} diff --git a/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginWebValidatorTests.cs b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginWebValidatorTests.cs index 511d602e3..13adaef5f 100644 --- a/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginWebValidatorTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginWebValidatorTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Customers; @@ -60,7 +61,7 @@ public async Task PerStoreOn_ValidCredentials_ScopesLookupAndLoginToCurrentStore { _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), StoreId)) .ReturnsAsync(new Customer { Active = true }); - _customerManagerServiceMock.Setup(m => m.LoginCustomer(It.IsAny(), It.IsAny(), StoreId)) + _customerManagerServiceMock.Setup(m => m.LoginCustomer(It.IsAny(), It.IsAny(), StoreId, It.IsAny())) .ReturnsAsync(CustomerLoginResults.Successful); var validator = CreateValidator(apiEnabled: true, perStore: true); @@ -68,7 +69,7 @@ public async Task PerStoreOn_ValidCredentials_ScopesLookupAndLoginToCurrentStore Assert.IsTrue(result.IsValid); _customerServiceMock.Verify(c => c.GetCustomerByEmail(It.IsAny(), StoreId), Times.Once); - _customerManagerServiceMock.Verify(m => m.LoginCustomer(It.IsAny(), It.IsAny(), StoreId), Times.Once); + _customerManagerServiceMock.Verify(m => m.LoginCustomer(It.IsAny(), It.IsAny(), StoreId, SignInSurface.FrontendApi), Times.Once); } [TestMethod] @@ -76,14 +77,14 @@ public async Task PerStoreOff_UsesGlobalLookup() { _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), "")) .ReturnsAsync(new Customer { Active = true }); - _customerManagerServiceMock.Setup(m => m.LoginCustomer(It.IsAny(), It.IsAny(), "")) + _customerManagerServiceMock.Setup(m => m.LoginCustomer(It.IsAny(), It.IsAny(), "", It.IsAny())) .ReturnsAsync(CustomerLoginResults.Successful); var validator = CreateValidator(apiEnabled: true, perStore: false); var result = await validator.ValidateAsync(ValidModel()); Assert.IsTrue(result.IsValid); - _customerManagerServiceMock.Verify(m => m.LoginCustomer(It.IsAny(), It.IsAny(), ""), Times.Once); + _customerManagerServiceMock.Verify(m => m.LoginCustomer(It.IsAny(), It.IsAny(), "", It.IsAny()), Times.Once); } [TestMethod] @@ -91,7 +92,7 @@ public async Task ExternalSignInRequired_Fails() { _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())) .ReturnsAsync(new Customer { Active = true }); - _customerManagerServiceMock.Setup(m => m.LoginCustomer(It.IsAny(), It.IsAny(), It.IsAny())) + _customerManagerServiceMock.Setup(m => m.LoginCustomer(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(CustomerLoginResults.ExternalSignInRequired); var validator = CreateValidator(apiEnabled: true, perStore: true); diff --git a/src/Tests/Grand.Web.Admin.Tests/Extensions/PanelSignInSurfaceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Extensions/PanelSignInSurfaceTests.cs new file mode 100644 index 000000000..43fb30a74 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Extensions/PanelSignInSurfaceTests.cs @@ -0,0 +1,19 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Web.AdminShared.Extensions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Extensions; + +[TestClass] +public class PanelSignInSurfaceTests +{ + [TestMethod] + [DataRow("Admin", SignInSurface.AdminPanel)] + [DataRow("admin", SignInSurface.AdminPanel)] + [DataRow("Store", SignInSurface.StorePanel)] + [DataRow("Vendor", SignInSurface.VendorPanel)] + [DataRow("", SignInSurface.Storefront)] + [DataRow(null, SignInSurface.Storefront)] + public void FromArea(string area, SignInSurface expected) => + Assert.AreEqual(expected, PanelSignInSurface.FromArea(area)); +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs index 194b5b3a5..e90789a8f 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; @@ -50,6 +51,8 @@ public void Init() _vendorService = new Mock(); _messageProviderService = new Mock(); _signInPolicy = new Mock(); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); var translationService = new Mock(); translationService.Setup(x => x.GetResource(It.IsAny())).Returns((string key) => key); @@ -128,8 +131,7 @@ public async Task SendRecoveryMessage_AdminOfAdminPanel_StoresTokenAndArea_Sends public async Task SendRecoveryMessage_ExternalSignInRequired_NoMessage() { GrantAdminPanel(); - _signInPolicy.Setup(x => x.RequiredProvider(_customer)) - .ReturnsAsync(new Mock().Object); + _signInPolicy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); await _service.SendRecoveryMessage(Email, "Admin"); @@ -284,8 +286,7 @@ public async Task ResetPassword_ExternalSignInRequired_WrongToken_DoesNotChangeP { GrantAdminPanel(); GiveToken(DateTime.UtcNow); - _signInPolicy.Setup(x => x.RequiredProvider(_customer)) - .ReturnsAsync(new Mock().Object); + _signInPolicy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); Assert.AreEqual("Account.PasswordRecovery.WrongToken", await _service.ValidateToken(Email, Token, "Admin")); var result = await _service.ResetPassword(Email, Token, "Admin", "N3w-password"); diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs index f042be342..4f92b7215 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; @@ -33,6 +34,8 @@ public class LoginValidatorExternalSignInTests public void Init() { _policy = new Mock(); + _policy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); _provider = new Mock(); _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); _customerService = new Mock(); @@ -50,12 +53,15 @@ public void Init() .Returns("Sign in with {0}"); } - private LoginValidator Validator() + private LoginValidator Validator(string area = null) { var services = new ServiceCollection(); services.AddSingleton(new Mock().Object); var http = new Mock(); - http.Setup(a => a.HttpContext).Returns(new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }); + var httpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + if (area != null) + httpContext.Request.RouteValues["area"] = area; + http.Setup(a => a.HttpContext).Returns(httpContext); return new LoginValidator( new List>(), @@ -67,11 +73,30 @@ private LoginValidator Validator() [TestMethod] public async Task CorrectPassword_ExternalSignInRequired_Failure() { - _policy.Setup(x => x.RequiredProvider(_customer)).ReturnsAsync(_provider.Object); + _policy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, _provider.Object)); var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" }); Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Sign in with OpenID Connect")); } + [TestMethod] + [DataRow("Admin", SignInSurface.AdminPanel)] + [DataRow("Store", SignInSurface.StorePanel)] + [DataRow("Vendor", SignInSurface.VendorPanel)] + public async Task CorrectPassword_PolicyAskedWithPanelSurface(string area, SignInSurface expected) + { + await Validator(area).ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" }); + _policy.Verify(x => x.PasswordSignIn(_customer, expected), Times.Once); + } + + [TestMethod] + public async Task CorrectPassword_RefusedWithoutProvider_UnavailableMessage() + { + _policy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())) + .ReturnsAsync(new PasswordSignInDecision(false, null)); + var result = await Validator("Admin").ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" }); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.Login.ExternalSignInUnavailable")); + } + [TestMethod] public async Task CorrectPassword_NoPolicy_Valid() { @@ -84,6 +109,6 @@ public async Task WrongPassword_PolicyNotAsked() { var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "wrong" }); Assert.IsFalse(result.IsValid); - _policy.Verify(x => x.RequiredProvider(It.IsAny()), Times.Never); + _policy.Verify(x => x.PasswordSignIn(It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs index 01676b3e6..675424d0f 100644 --- a/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Messages; @@ -20,7 +21,7 @@ public async Task ExternalSignInRequired_NoMessage_NoToken() var messages = new Mock(); var policy = new Mock(); var customer = new Customer { Id = "c1" }; - policy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync(new Mock().Object); + policy.Setup(x => x.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); var handler = new PasswordRecoverySendCommandHandler(customerService.Object, messages.Object, policy.Object); Assert.IsTrue(await handler.Handle(new PasswordRecoverySendCommand { @@ -35,8 +36,11 @@ public async Task ExternalSignInRequired_NoMessage_NoToken() public async Task NullCustomer_NoMessage() { var messages = new Mock(); + var policy = new Mock(); + policy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.Storefront)) + .ReturnsAsync(PasswordSignInDecision.Password); var handler = new PasswordRecoverySendCommandHandler(new Mock().Object, messages.Object, - new Mock().Object); + policy.Object); Assert.IsTrue(await handler.Handle(new PasswordRecoverySendCommand { Store = new Grand.Domain.Stores.Store(), Language = new Language { Id = "l1" } @@ -49,8 +53,11 @@ public async Task NullCustomer_NoMessage() public async Task PasswordAllowed_MessageSent() { var messages = new Mock(); + var policy = new Mock(); + policy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.Storefront)) + .ReturnsAsync(PasswordSignInDecision.Password); var handler = new PasswordRecoverySendCommandHandler(new Mock().Object, messages.Object, - new Mock().Object); + policy.Object); await handler.Handle(new PasswordRecoverySendCommand { Customer = new Customer { Id = "c1" }, Store = new Grand.Domain.Stores.Store(), Language = new Language { Id = "l1" } }, CancellationToken.None); diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs index 2782f4efb..9cec195e4 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs @@ -63,7 +63,7 @@ public void Init() private async Task PassTwoFactor(string returnUrl) { - _manager.Setup(x => x.LoginCustomer("a@b.c", "p", It.IsAny())).ReturnsAsync(CustomerLoginResults.RequiresTwoFactor); + _manager.Setup(x => x.LoginCustomer("a@b.c", "p", It.IsAny(), SignInSurface.Storefront)).ReturnsAsync(CustomerLoginResults.RequiresTwoFactor); await _controller.Login(new LoginModel { Email = "a@b.c", Password = "p" }, returnUrl); var twoFactor = new Mock(); twoFactor.Setup(x => x.AuthenticateTwoFactor(It.IsAny(), "123", _customer, It.IsAny())) diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs index c3d84e61d..cae740609 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; @@ -35,6 +36,8 @@ public class LoginValidatorExternalSignInTests public void Init() { _policy = new Mock(); + _policy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); _provider = new Mock(); _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); _customerService = new Mock(); @@ -74,7 +77,7 @@ private LoginValidator Validator() [TestMethod] public async Task CorrectPassword_ExternalSignInRequired_Failure() { - _policy.Setup(x => x.RequiredProvider(_customer)).ReturnsAsync(_provider.Object); + _policy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, _provider.Object)); var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" }); Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Sign in with OpenID Connect")); } @@ -91,6 +94,6 @@ public async Task WrongPassword_PolicyNotAsked() { var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "wrong" }); Assert.IsFalse(result.IsValid); - _policy.Verify(x => x.RequiredProvider(It.IsAny()), Times.Never); + _policy.Verify(x => x.PasswordSignIn(It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs index 589a13413..89595b5e0 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; @@ -41,6 +42,8 @@ public void Init() var translation = new Mock(); translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); _signInPolicy = new Mock(); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(PasswordSignInDecision.Password); _validator = new PasswordRecoveryConfirmValidator( new List>(), @@ -65,8 +68,7 @@ public async Task ValidLink_Valid() [TestMethod] public async Task ExternalSignInRequired_WrongToken() { - _signInPolicy.Setup(x => x.RequiredProvider(_customer)) - .ReturnsAsync(new Mock().Object); + _signInPolicy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); var result = await Validate(); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs index d13607611..83d53e08a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs @@ -6,6 +6,7 @@ using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Common; using Grand.Web.Common.Controllers; @@ -72,7 +73,8 @@ public virtual async Task Index(LoginModel model, { var loginResult = await _customerManagerService.LoginCustomer( - _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password); + _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password, "", + PanelSignInSurface.FromArea(GetCurrentArea())); switch (loginResult) { case CustomerLoginResults.Successful: diff --git a/src/Web/Grand.Web.AdminShared/Extensions/PanelSignInSurface.cs b/src/Web/Grand.Web.AdminShared/Extensions/PanelSignInSurface.cs new file mode 100644 index 000000000..891b5c371 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Extensions/PanelSignInSurface.cs @@ -0,0 +1,14 @@ +using Grand.Business.Core.Interfaces.Authentication; + +namespace Grand.Web.AdminShared.Extensions; + +public static class PanelSignInSurface +{ + /// The panel a login or password recovery belongs to; an unknown area never counts as the Admin panel + public static SignInSurface FromArea(string area) => area?.ToLowerInvariant() switch { + "admin" => SignInSurface.AdminPanel, + "store" => SignInSurface.StorePanel, + "vendor" => SignInSurface.VendorPanel, + _ => SignInSurface.Storefront + }; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs b/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs index c418b52c5..00e117d21 100644 --- a/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs @@ -8,6 +8,7 @@ using Grand.Domain.Customers; using Grand.Domain.Permissions; using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Interfaces; namespace Grand.Web.AdminShared.Services; @@ -108,7 +109,7 @@ private async Task GetPanelCustomer(string email, string area) return null; //an account that signs in only with the identity provider has no password to recover: no message is sent, and //a link sent before reads as a wrong link - if (await externalSignInPolicy.RequiredProvider(customer) != null) + if (!(await externalSignInPolicy.PasswordSignIn(customer, PanelSignInSurface.FromArea(area))).Allowed) return null; return await HasPanelAccess(customer, area) ? customer : null; diff --git a/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs index 23cab5114..6e700b8a7 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs @@ -13,7 +13,9 @@ using Grand.Web.AdminShared.Models.Common; using Grand.Web.Common.Validators; using Grand.Mediator; +using Grand.Web.AdminShared.Extensions; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace Grand.Web.AdminShared.Validators.Common; @@ -81,11 +83,14 @@ public LoginValidator( await contextAccessor.HttpContext!.RequestServices.GetRequiredService() .Publish(new CustomerLoginFailedEvent(customer), _); } - else if (await externalSignInPolicy.RequiredProvider(customer) is { } provider) + else if (await externalSignInPolicy.PasswordSignIn(customer, PanelSignInSurface.FromArea( + contextAccessor.HttpContext?.GetRouteValue("area") as string)) is { Allowed: false } decision) { - context.AddFailure(string.Format( - translationService.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"), - provider.FriendlyName)); + context.AddFailure(decision.Provider != null + ? string.Format( + translationService.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"), + decision.Provider.FriendlyName) + : translationService.GetResource("Account.Login.ExternalSignInUnavailable")); } break; diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index c917139c6eba83eb2983e998e21a58d8878b005f..41dc6eb347a99ecdfa46561813ef1e25ba71039a 100644 GIT binary patch delta 302 zcmca{H-6c}_zeZ~Cbv8mm|V4U7VyFbti9ll$p~mI_StUS~ML_Xh&Am#vK sP9Ww2Vs0Sj0b*Vt<^y7WAQk{(K_C_aVqqW_0b)@g7TdmNzPOG*0Q(|mYXATM delta 106 zcmWN=yAeP@6hP7OxBje8H?IaAY%0`)Ko{mkVwqMX0-d;L=A55Cc2Ay^cgT@ c#Fz^X3}MW This account signs in with {0}. Use the {0} button. + + Signing in is possible only through the identity provider, which cannot be reached now. Try again later or contact the store. + Sign in with {0} diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs index 6f3147e1a..15a2a12e7 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs @@ -26,7 +26,7 @@ public PasswordRecoverySendCommandHandler( public async Task Handle(PasswordRecoverySendCommand request, CancellationToken cancellationToken) { //an account that signs in only with the identity provider has no password to recover; the page answers as usual - if (request.Customer == null || await _externalSignInPolicy.RequiredProvider(request.Customer) != null) + if (request.Customer == null || !(await _externalSignInPolicy.PasswordSignIn(request.Customer, SignInSurface.Storefront)).Allowed) return true; //save token and current date diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 7ded4d710..d3e4a7655 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -205,7 +205,8 @@ public virtual async Task Login(LoginModel model, string returnUr { var loginResult = await _customerManagerService.LoginCustomer( - _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password, CustomerStoreId); + _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password, CustomerStoreId, + SignInSurface.Storefront); switch (loginResult) { case CustomerLoginResults.Successful: diff --git a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs index 6938f116f..d3ff57274 100644 --- a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs @@ -85,11 +85,13 @@ public LoginValidator( await contextAccessor.HttpContext!.RequestServices.GetRequiredService() .Publish(new CustomerLoginFailedEvent(customer), _); } - else if (await externalSignInPolicy.RequiredProvider(customer) is { } provider) + else if (await externalSignInPolicy.PasswordSignIn(customer, SignInSurface.Storefront) is { Allowed: false } decision) { - context.AddFailure(string.Format( - translationService.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"), - provider.FriendlyName)); + context.AddFailure(decision.Provider != null + ? string.Format( + translationService.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"), + decision.Provider.FriendlyName) + : translationService.GetResource("Account.Login.ExternalSignInUnavailable")); } break; diff --git a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs index ba6b59e9d..6f37fa6fb 100644 --- a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs @@ -66,7 +66,7 @@ public PasswordRecoveryConfirmValidator( //a link sent before the account had to sign in through the provider reads as a wrong link if (customer is not null && (!customer.IsPasswordRecoveryTokenValid(x.Token) || - await externalSignInPolicy.RequiredProvider(customer) != null)) + !(await externalSignInPolicy.PasswordSignIn(customer, SignInSurface.Storefront)).Allowed)) context.AddFailure(translationService.GetResource("Account.PasswordRecovery.WrongToken")); if (customer is not null && customer.IsPasswordRecoveryLinkExpired(customerSettings)) From a2f67c9d47580715cc102ba6eee74fd5c327121e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 22:34:01 +0200 Subject: [PATCH 4/9] Sign-in policy: keep guest token refresh, short-circuit API user login in ProviderOnly, assert surfaces in tests --- .../Controllers/TokenWebController.cs | 5 ++-- .../Validators/Common/LoginValidator.cs | 6 +++- .../Services/CustomerManagerServiceTests.cs | 21 +++++++++++++- .../Controllers/TokenWebControllerTests.cs | 28 ++++++++++++++++--- .../Common/LoginValidatorProviderOnlyTests.cs | 11 +++++++- .../PanelPasswordRecoveryServiceTests.cs | 10 +++++++ ...PasswordRecoverySendCommandHandlerTests.cs | 5 +--- .../LoginValidatorExternalSignInTests.cs | 6 ++-- ...veryConfirmValidatorExternalSignInTests.cs | 4 +-- 9 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs index 01e234e3b..d6a67b788 100644 --- a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs +++ b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs @@ -147,8 +147,9 @@ public async Task Refresh([FromBody] TokenDto tokenDto) return BadRequest("Invalid access token"); //tokens obtained with the password before the account had to sign in through the provider are not renewed: - //the provider could not stop them for a user it disabled - if (!(await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.FrontendApi)).Allowed) + //the provider could not stop them for a user it disabled. Guests never had a password: their tokens stay + if (!string.IsNullOrEmpty(customer.Email) && + !(await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.FrontendApi)).Allowed) return BadRequest("Invalid refresh token"); //rebuild the claims from the resolved customer (registered carry id/email/token, guests carry the guid) diff --git a/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs b/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs index 2d1cb4f25..40d9c6014 100644 --- a/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs +++ b/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs @@ -21,10 +21,14 @@ public LoginValidator( if (!apiConfig.Enabled) RuleFor(x => x).Must(_ => false).WithMessage("API is disabled"); - //provider-only: no password sign-in outside the Admin login page, API users included + //provider-only: no password sign-in outside the Admin login page, API users included; the other rules + //(password decoding and check) do not run if (oidcLoginConfig.ProviderOnly) + { RuleFor(x => x).Must(_ => false) .WithMessage("Sign-in with API users is disabled: use access tokens from the identity provider"); + return; + } RuleFor(x => x.Email).NotEmpty().WithMessage("Email is required"); RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs index 7c546c2ae..74ae4d2cf 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs @@ -7,6 +7,7 @@ using Grand.Business.Customers.Services; using Grand.Domain.Customers; using Grand.Mediator; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -25,6 +26,7 @@ public class CustomerManagerServiceTests private Mock _groupServiceMock; private Mock _mediatorMock; private Mock _signInPolicy; + private Mock> _logger; [TestInitialize] public void Init() @@ -39,6 +41,7 @@ public void Init() entered == stored); _mediatorMock = new Mock(); _customerHistoryPasswordServiceMock = new Mock(); + _logger = new Mock>(); _signInPolicy = new Mock(); _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) .ReturnsAsync(PasswordSignInDecision.Password); @@ -48,7 +51,7 @@ public void Init() _customerManagerService = new CustomerManagerService(_customerServiceMock.Object, _groupServiceMock.Object, _encryptionServiceMock.Object, _mediatorMock.Object, _customerHistoryPasswordServiceMock.Object, _customerSettings, - _signInPolicy.Object, NullLogger.Instance); + _signInPolicy.Object, _logger.Object); } [TestMethod] @@ -71,6 +74,22 @@ public async Task LoginCustomer_PassesSurfaceToPolicy() _signInPolicy.Verify(x => x.PasswordSignIn(It.IsAny(), SignInSurface.AdminPanel), Times.Once); } + [TestMethod] + public async Task LoginCustomer_BreakGlass_SuccessfulAndWarningLogged() + { + var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; + _customerServiceMock.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + _groupServiceMock.Setup(c => c.IsRegistered(It.IsAny())).ReturnsAsync(true); + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PasswordSignInDecision(true, null, true)); + + var result = await _customerManagerService.LoginCustomer("admin@admin.com", "123456", "", SignInSurface.AdminPanel); + + Assert.AreEqual(CustomerLoginResults.Successful, result); + _logger.Verify(x => x.Log(LogLevel.Warning, It.IsAny(), It.IsAny(), null, + It.IsAny>()), Times.Once); + } + [TestMethod] public async Task LoginCustomer_RefusedByPolicy_ExternalSignInRequired() { diff --git a/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs b/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs index 4c778d5e3..04dbf11e8 100644 --- a/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs @@ -37,7 +37,7 @@ public void Init() _mediatorMock = new Mock(); _refreshTokenServiceMock = new Mock(); _signInPolicyMock = new Mock(); - _signInPolicyMock.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + _signInPolicyMock.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.FrontendApi)) .ReturnsAsync(PasswordSignInDecision.Password); _refreshTokenServiceMock.Setup(r => r.GenerateRefreshToken()).Returns("rt"); @@ -125,7 +125,7 @@ private Customer RefreshableCustomer() public async Task Refresh_ProviderRequired_InvalidRefreshToken() { var customer = RefreshableCustomer(); - _signInPolicyMock.Setup(p => p.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, Mock.Of())); + _signInPolicyMock.Setup(p => p.PasswordSignIn(customer, SignInSurface.FrontendApi)).ReturnsAsync(new PasswordSignInDecision(false, Mock.Of())); var controller = CreateController(perStore: false); var result = await controller.Refresh(new TokenDto { AccessToken = "access", RefreshToken = "rt" }); @@ -136,16 +136,36 @@ public async Task Refresh_ProviderRequired_InvalidRefreshToken() _mediatorMock.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); } + //guests never had a password: their tokens are renewed even when every password sign-in is refused + [TestMethod] + public async Task Refresh_Guest_PolicyRefuses_StillOk() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("Guid", Guid.NewGuid().ToString()) })); + _refreshTokenServiceMock.Setup(r => r.GetPrincipalFromToken("access")).Returns(principal); + var guest = new Customer { Id = "g1", CustomerGuid = Guid.NewGuid() }; + _customerServiceMock.Setup(c => c.GetCustomerByGuid(It.IsAny())).ReturnsAsync(guest); + _refreshTokenServiceMock.Setup(r => r.GetCustomerRefreshToken(guest)) + .ReturnsAsync(new RefreshToken { Token = "rt", ValidTo = DateTime.UtcNow.AddDays(1), RefreshId = "rid" }); + _signInPolicyMock.Setup(p => p.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PasswordSignInDecision(false, null)); + + var result = await CreateController(perStore: false) + .Refresh(new TokenDto { AccessToken = "access", RefreshToken = "rt" }); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsTrue(_capturedCommand.Claims.ContainsKey("Guid")); + } + [TestMethod] public async Task Refresh_PasswordAllowed_Ok() { var customer = RefreshableCustomer(); - _signInPolicyMock.Setup(p => p.PasswordSignIn(customer, It.IsAny())).ReturnsAsync(PasswordSignInDecision.Password); + _signInPolicyMock.Setup(p => p.PasswordSignIn(customer, SignInSurface.FrontendApi)).ReturnsAsync(PasswordSignInDecision.Password); var controller = CreateController(perStore: false); var result = await controller.Refresh(new TokenDto { AccessToken = "access", RefreshToken = "rt" }); Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - _signInPolicyMock.Verify(p => p.PasswordSignIn(customer, It.IsAny()), Times.Once); + _signInPolicyMock.Verify(p => p.PasswordSignIn(customer, SignInSurface.FrontendApi), Times.Once); } } diff --git a/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs index 4342425cd..8e9df9b05 100644 --- a/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginValidatorProviderOnlyTests.cs @@ -21,7 +21,7 @@ public class LoginValidatorProviderOnlyTests private static LoginValidator CreateValidator(OidcSignInMode mode) { var customerService = new Mock(); - customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + customerService.Setup(x => x.GetCustomerByEmail(It.IsAny())) .ReturnsAsync(new Customer { Active = true }); var userApiService = new Mock(); userApiService.Setup(x => x.GetUserByEmail(It.IsAny())) @@ -47,6 +47,15 @@ public async Task ProviderOnly_ValidApiUser_Refused() Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == Message)); } + //nothing else runs: a password that is not base64 does not throw + [TestMethod] + public async Task ProviderOnly_PasswordNotBase64_RefusedWithoutThrowing() + { + var result = await CreateValidator(OidcSignInMode.ProviderOnly) + .ValidateAsync(new LoginModel { Email = "user@x.com", Password = "not base64!" }); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == Message)); + } + [TestMethod] public async Task Mixed_ValidApiUser_Valid() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs index e90789a8f..2d5745107 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs @@ -169,6 +169,16 @@ public async Task SendRecoveryMessage_StoreManagerOnAdminPanel_SendsNothing() VerifyMessageSent(Times.Never()); } + [TestMethod] + public async Task SendRecoveryMessage_StorePanel_AsksPolicyWithStorePanelSurface() + { + GrantStorePanel(); + + await _service.SendRecoveryMessage(Email, "Store"); + + _signInPolicy.Verify(x => x.PasswordSignIn(_customer, SignInSurface.StorePanel), Times.Once); + } + [TestMethod] public async Task SendRecoveryMessage_StoreManagerOnStorePanel_SendsWithStoreArea() { diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs index 675424d0f..a8c721e88 100644 --- a/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs @@ -36,11 +36,8 @@ public async Task ExternalSignInRequired_NoMessage_NoToken() public async Task NullCustomer_NoMessage() { var messages = new Mock(); - var policy = new Mock(); - policy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.Storefront)) - .ReturnsAsync(PasswordSignInDecision.Password); var handler = new PasswordRecoverySendCommandHandler(new Mock().Object, messages.Object, - policy.Object); + new Mock().Object); Assert.IsTrue(await handler.Handle(new PasswordRecoverySendCommand { Store = new Grand.Domain.Stores.Store(), Language = new Language { Id = "l1" } diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs index cae740609..e37250039 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs @@ -36,7 +36,7 @@ public class LoginValidatorExternalSignInTests public void Init() { _policy = new Mock(); - _policy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + _policy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.Storefront)) .ReturnsAsync(PasswordSignInDecision.Password); _provider = new Mock(); _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); @@ -77,7 +77,7 @@ private LoginValidator Validator() [TestMethod] public async Task CorrectPassword_ExternalSignInRequired_Failure() { - _policy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, _provider.Object)); + _policy.Setup(x => x.PasswordSignIn(_customer, SignInSurface.Storefront)).ReturnsAsync(new PasswordSignInDecision(false, _provider.Object)); var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" }); Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Sign in with OpenID Connect")); } @@ -94,6 +94,6 @@ public async Task WrongPassword_PolicyNotAsked() { var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "wrong" }); Assert.IsFalse(result.IsValid); - _policy.Verify(x => x.PasswordSignIn(It.IsAny(), It.IsAny()), Times.Never); + _policy.Verify(x => x.PasswordSignIn(It.IsAny(), SignInSurface.Storefront), Times.Never); } } diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs index 89595b5e0..184dac503 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs @@ -42,7 +42,7 @@ public void Init() var translation = new Mock(); translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); _signInPolicy = new Mock(); - _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), It.IsAny())) + _signInPolicy.Setup(x => x.PasswordSignIn(It.IsAny(), SignInSurface.Storefront)) .ReturnsAsync(PasswordSignInDecision.Password); _validator = new PasswordRecoveryConfirmValidator( @@ -68,7 +68,7 @@ public async Task ValidLink_Valid() [TestMethod] public async Task ExternalSignInRequired_WrongToken() { - _signInPolicy.Setup(x => x.PasswordSignIn(_customer, It.IsAny())).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); + _signInPolicy.Setup(x => x.PasswordSignIn(_customer, SignInSurface.Storefront)).ReturnsAsync(new PasswordSignInDecision(false, new Mock().Object)); var result = await Validate(); From f69b4dbef71bcbce2b9f6c8397daf8bb0652f021 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 22:46:54 +0200 Subject: [PATCH 5/9] OpenID Connect provider-only mode: link every account by the provider-verified e-mail, refuse an unverified one --- .../Services/OidcCustomerResolver.cs | 24 ++++- .../Authentication/OidcLoginResult.cs | 3 +- .../OidcCustomerResolverLoginTests.cs | 93 ++++++++++++++++++- .../Services/OidcCustomerResolverTests.cs | 17 +++- 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index e69024cf7..b2b16691c 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -25,7 +25,9 @@ namespace Grand.Business.Authentication.Services; /// Maps a validated access token or a browser sign-in to a customer: stored subject link; customer with the verified e-mail (link /// stored and marked as made by e-mail; never a back-office account or an account with two-factor authentication, which connect /// only with their password); registration with the Registered group only. A link made by e-mail is re-checked on every use -/// against the rules of a first link. Only active, registered, non-system accounts are used, +/// against the rules of a first link. +/// In ProviderOnly (OidcLogin:SignInMode) a browser sign-in links any account by a provider-verified e-mail and refuses an +/// unverified one. Only active, registered, non-system accounts are used, /// and one customer holds at most one identity (enforced by the link's identifier, see ). /// With per-store identity a subject has one link per store (plus the link of a customer of no store, valid in /// every store). A unique index on (provider, identifier, store) makes the first concurrent requests of one user @@ -46,6 +48,7 @@ public class OidcCustomerResolver : IOidcCustomerResolver private readonly IPermissionService _permissionService; private readonly IMediator _mediator; private readonly OidcResourceServerConfig _config; + private readonly OidcLoginConfig _loginConfig; private readonly CustomerConfig _customerConfig; private readonly ILogger _logger; @@ -58,6 +61,7 @@ public OidcCustomerResolver( IPermissionService permissionService, IMediator mediator, OidcResourceServerConfig config, + OidcLoginConfig loginConfig, CustomerConfig customerConfig, ILogger logger) { @@ -69,6 +73,7 @@ public OidcCustomerResolver( _permissionService = permissionService; _mediator = mediator; _config = config; + _loginConfig = loginConfig; _customerConfig = customerConfig; _logger = logger; } @@ -88,7 +93,8 @@ public virtual async Task Resolve(ClaimsPrincipal principal, var linkedResult = await Usable(linkedCustomer, sub, storeId); //a link made by e-mail proves no more than the e-mail: it stops serving an account that has since //become one that connects only with its password - if (linkedResult.Succeeded && link.LinkedByEmail && await RequiresPassword(linkedCustomer, storeId)) + if (linkedResult.Succeeded && link.LinkedByEmail && !_loginConfig.ProviderOnly && + await RequiresPassword(linkedCustomer, storeId)) return AccountConnectionRequired(linkedCustomer, sub); return linkedResult; } @@ -146,8 +152,9 @@ public virtual async Task ResolveForLogin(string sub, string em if (reason != Unusable.None) return OidcLoginResult.Refused(ToRefusal(reason)); - //a link made by e-mail (by an access token or an earlier sign-in) is held to the rules of a first link - if (link.LinkedByEmail && (!emailVerified || await RequiresPassword(linked, storeId))) + //a link made by e-mail (by an access token or an earlier sign-in) is held to the rules of a first link; + //provider-only installations trust the provider for every account + if (!_loginConfig.ProviderOnly && link.LinkedByEmail && (!emailVerified || await RequiresPassword(linked, storeId))) return OidcLoginResult.LinkRequired(linked); return OidcLoginResult.SignIn(linked); @@ -156,6 +163,13 @@ public virtual async Task ResolveForLogin(string sub, string em if (string.IsNullOrWhiteSpace(email)) return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity); + //provider-only: an account (new or existing) is given only to an e-mail the provider verified + if (_loginConfig.ProviderOnly && !emailVerified) + { + _logger.LogWarning("Sign-in subject {Subject}: the provider did not verify the e-mail", sub); + return OidcLoginResult.Refused(OidcLoginRefusal.EmailNotVerified); + } + var customer = await _customerService.GetCustomerByEmail(email, _customerConfig.RegisterCustomersPerStore ? storeId : ""); if (customer == null) @@ -182,7 +196,7 @@ public virtual async Task ResolveForLogin(string sub, string em //the first link of a back-office account, of an account protected by two-factor authentication, and of any account //whose e-mail nobody verified, needs the account's password (and its second factor) - if (!emailVerified || await RequiresPassword(customer, storeId)) + if (!_loginConfig.ProviderOnly && (!emailVerified || await RequiresPassword(customer, storeId))) return OidcLoginResult.LinkRequired(customer); return await Link(customer, sub, email, storeId, true) switch { diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs index 42d48b7e8..76867a2dd 100644 --- a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs @@ -20,7 +20,8 @@ public enum OidcLoginRefusal AccountInactive, AccountUnusable, LinkedToOtherIdentity, - RegistrationDisabled + RegistrationDisabled, + EmailNotVerified } public enum OidcLinkResult diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs index aa4006dba..3e79fa33d 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -32,6 +32,7 @@ public class OidcCustomerResolverLoginTests private Mock _permissionService; private Mock _mediator; private OidcResourceServerConfig _config; + private OidcLoginConfig _loginConfig; private CustomerConfig _customerConfig; private CustomerSettings _customerSettings; @@ -48,6 +49,7 @@ public void Init() _mediator = new Mock(); _config = new OidcResourceServerConfig { Enabled = true }; _customerConfig = new CustomerConfig(); + _loginConfig = new OidcLoginConfig(); _customerSettings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; _settingService.Setup(x => x.LoadSetting(It.IsAny())).ReturnsAsync(_customerSettings); _customerService.Setup(x => x.InsertGuestCustomer(It.IsAny())) @@ -56,7 +58,7 @@ public void Init() private OidcCustomerResolver Resolver(IRepository links = null) => new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, - _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); + _groupService.Object, _permissionService.Object, _mediator.Object, _config, _loginConfig, _customerConfig, NullLogger.Instance); private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName, string storeId = null, bool byEmail = false) => @@ -235,6 +237,95 @@ public async Task LinkedAdmin_SignIn() Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); } + [TestMethod] + public async Task ProviderOnly_Staff_LinkedByVerifiedEmail() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var staff = new Customer { Id = "c1", Active = true, StaffStoreId = "s1" }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(staff); + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreEqual("c1", result.Customer.Id); + Assert.IsTrue((await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")).LinkedByEmail); + } + + [TestMethod] + public async Task ProviderOnly_TwoFactor_LinkedByVerifiedEmail() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _customerSettings.TwoFactorAuthenticationEnabled = true; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(TwoFactorCustomer()); + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.IsNotNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task ProviderOnly_UnverifiedEmail_ExistingAccount_Refused() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", false, Store); + Assert.AreEqual(OidcLoginRefusal.EmailNotVerified, result.Refusal); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task ProviderOnly_UnverifiedEmail_Unknown_RefusedNotRegistered() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var result = await Resolver().ResolveForLogin("sub-new", "new@shop.com", false, Store); + Assert.AreEqual(OidcLoginOutcome.Refused, result.Outcome); + Assert.AreEqual(OidcLoginRefusal.EmailNotVerified, result.Refusal); + } + + [TestMethod] + public async Task ProviderOnly_VerifiedEmail_Unknown_RegistrationRequired() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + Assert.AreEqual(OidcLoginOutcome.RegistrationRequired, + (await Resolver().ResolveForLogin("sub-new", "new@shop.com", true, Store)).Outcome); + } + + [TestMethod] + public async Task ProviderOnly_LinkMadeByEmail_StaffSignsIn() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _customerService.Setup(x => x.GetCustomerById("c1")) + .ReturnsAsync(new Customer { Id = "c1", Active = true, StaffStoreId = "s1" }); + await Link("c1", byEmail: true); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + } + + [TestMethod] + public async Task ProviderOnly_LinkMadeByEmail_Unverified_SignsIn() + { + //the link exists; the provider's later claims do not re-open the first-link rules + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + await Link("c1", byEmail: true); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", false, Store)).Outcome); + } + + [TestMethod] + public async Task ProviderOnly_ExistingAccount_LinkedToOtherSubject_Refused() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + await Link("c1", "other-sub"); + Assert.AreEqual(OidcLoginRefusal.LinkedToOtherIdentity, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task Mixed_Staff_LinkRequired() + { + var staff = new Customer { Id = "c1", Active = true, StaffStoreId = "s1" }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(staff); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + } + [TestMethod] public async Task ExistingCustomerLinkedToOtherSubject_Refused() { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs index 96ad7dbf3..f96c4296a 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -32,6 +32,7 @@ public class OidcCustomerResolverTests private Mock _permissionService; private Mock _mediator; private OidcResourceServerConfig _config; + private OidcLoginConfig _loginConfig; private CustomerConfig _customerConfig; private CustomerSettings _customerSettings; @@ -48,6 +49,7 @@ public void Init() _mediator = new Mock(); _config = new OidcResourceServerConfig { Enabled = true, AutoRegister = false }; _customerConfig = new CustomerConfig(); + _loginConfig = new OidcLoginConfig(); _customerSettings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; _settingService.Setup(x => x.LoadSetting(It.IsAny())).ReturnsAsync(_customerSettings); _customerService.Setup(x => x.InsertGuestCustomer(It.IsAny())) @@ -56,7 +58,7 @@ public void Init() private OidcCustomerResolver Resolver(IRepository links = null) => new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, - _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); + _groupService.Object, _permissionService.Object, _mediator.Object, _config, _loginConfig, _customerConfig, NullLogger.Instance); private static ClaimsPrincipal Principal(string sub = "sub-1", string email = "John@Example.com", string emailVerified = "true") @@ -446,6 +448,19 @@ public async Task LinkMadeByEmail_AccountNowPasswordOnly_Forbidden(string kind) Assert.AreEqual(OidcCustomerResolver.AccountConnectionRequiredDescription, result.Description); } + [TestMethod] + [DataRow("admin")] + [DataRow("staff")] + [DataRow("twoFactor")] + public async Task ProviderOnly_LinkMadeByEmail_PasswordOnlyAccount_Served(string kind) + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + await Link("c1", byEmail: true); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(PasswordOnlyCustomer(kind)); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + } + [TestMethod] public async Task LinkMadeByEmail_PlainCustomer_Succeeds() { From ea9f8f7ec1fafe12138e8437ced7aa396931a595 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 22:57:16 +0200 Subject: [PATCH 6/9] Guest checkout is off while the identity provider is the only sign-in --- .../Orders/PlaceOrderCommandHandler.cs | 6 ++++- .../Extensions/OrderSettingsExtensions.cs | 14 +++++++++++ .../OrderSettingsExtensionsTests.cs | 23 +++++++++++++++++++ .../Controllers/CheckoutController.cs | 14 +++++++---- .../Controllers/ShoppingCartController.cs | 9 ++++++-- .../GetMiniShoppingCartHandler.cs | 6 ++++- .../ShoppingCart/GetShoppingCartHandler.cs | 6 ++++- 7 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 src/Business/Grand.Business.Core/Extensions/OrderSettingsExtensions.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Extensions/OrderSettingsExtensionsTests.cs diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs index f2e326ed2..60803f6ef 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs @@ -24,6 +24,7 @@ using Grand.Domain.Payments; using Grand.Domain.Shipping; using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure; using Grand.SharedKernel; using Grand.SharedKernel.Extensions; @@ -50,6 +51,7 @@ public class PlaceOrderCommandHandler : IRequestHandler PreparePlaceOrderDetails() throw new ArgumentException("Customer is not set"); //check whether customer is guest - if (await _groupService.IsGuest(details.Customer) && !_orderSettings.AnonymousCheckoutAllowed) + if (await _groupService.IsGuest(details.Customer) && !_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) throw new GrandException("Anonymous checkout is not allowed"); //billing address diff --git a/src/Business/Grand.Business.Core/Extensions/OrderSettingsExtensions.cs b/src/Business/Grand.Business.Core/Extensions/OrderSettingsExtensions.cs new file mode 100644 index 000000000..2a939bd6a --- /dev/null +++ b/src/Business/Grand.Business.Core/Extensions/OrderSettingsExtensions.cs @@ -0,0 +1,14 @@ +using Grand.Domain.Orders; +using Grand.Infrastructure.Configuration; + +namespace Grand.Business.Core.Extensions; + +public static class OrderSettingsExtensions +{ + /// + /// Checkout as a guest: the store's setting, always off when the identity provider is the only way to an account + /// (OidcLogin:SignInMode = ProviderOnly); the stored setting is not changed + /// + public static bool AnonymousCheckoutAllowed(this OrderSettings settings, OidcLoginConfig loginConfig) => + settings.AnonymousCheckoutAllowed && loginConfig is not { ProviderOnly: true }; +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Extensions/OrderSettingsExtensionsTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Extensions/OrderSettingsExtensionsTests.cs new file mode 100644 index 000000000..201e95d4c --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Extensions/OrderSettingsExtensionsTests.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Extensions; +using Grand.Domain.Orders; +using Grand.Infrastructure.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Checkout.Tests.Extensions; + +[TestClass] +public class OrderSettingsExtensionsTests +{ + [TestMethod] + [DataRow(true, OidcSignInMode.Mixed, true)] + [DataRow(false, OidcSignInMode.Mixed, false)] + [DataRow(true, OidcSignInMode.ProviderOnly, false)] + [DataRow(false, OidcSignInMode.ProviderOnly, false)] + public void AnonymousCheckoutAllowed(bool stored, OidcSignInMode mode, bool expected) => + Assert.AreEqual(expected, new OrderSettings { AnonymousCheckoutAllowed = stored } + .AnonymousCheckoutAllowed(new OidcLoginConfig { SignInMode = mode })); + + [TestMethod] + public void NullConfig_StoredValue() => + Assert.IsTrue(new OrderSettings { AnonymousCheckoutAllowed = true }.AnonymousCheckoutAllowed(null)); +} diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index bb3746636..971499d6c 100644 --- a/src/Web/Grand.Web/Controllers/CheckoutController.cs +++ b/src/Web/Grand.Web/Controllers/CheckoutController.cs @@ -14,6 +14,7 @@ using Grand.Domain.Orders; using Grand.Domain.Payments; using Grand.Domain.Shipping; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure; using Grand.Infrastructure.Extensions; using Grand.SharedKernel.Attributes; @@ -51,6 +52,7 @@ public CheckoutController( IProductService productService, IShoppingCartValidator shoppingCartValidator, OrderSettings orderSettings, + OidcLoginConfig oidcLoginConfig, LoyaltyPointsSettings loyaltyPointsSettings, PaymentSettings paymentSettings, ShippingSettings shippingSettings, @@ -71,6 +73,7 @@ public CheckoutController( _productService = productService; _shoppingCartValidator = shoppingCartValidator; _orderSettings = orderSettings; + _oidcLoginConfig = oidcLoginConfig; _loyaltyPointsSettings = loyaltyPointsSettings; _paymentSettings = paymentSettings; _shippingSettings = shippingSettings; @@ -90,7 +93,7 @@ public virtual async Task Index() if (!cart.Any()) return RedirectToRoute("ShoppingCart"); - if (await _groupService.IsGuest(customer) && !_orderSettings.AnonymousCheckoutAllowed) + if (await _groupService.IsGuest(customer) && !_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) return Challenge(); //reset checkout data @@ -126,7 +129,7 @@ public virtual async Task Start() if (!cart.Any()) return RedirectToRoute("ShoppingCart"); - if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && !_orderSettings.AnonymousCheckoutAllowed) + if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && !_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) return Challenge(); //validation (checkout attributes) @@ -668,7 +671,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto public virtual async Task Completed(string orderId) { //validation - if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && !_orderSettings.AnonymousCheckoutAllowed) + if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && !_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) return Challenge(); Order order = null; @@ -770,7 +773,7 @@ public virtual async Task CompleteRedirectionPayment(string payme try { if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && - !_orderSettings.AnonymousCheckoutAllowed) + !_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) return Challenge(); var paymentTransaction = await _paymentTransactionService.GetById(paymentTransactionId); @@ -826,6 +829,7 @@ public virtual async Task CompleteRedirectionPayment(string payme private readonly IProductService _productService; private readonly IShoppingCartValidator _shoppingCartValidator; private readonly OrderSettings _orderSettings; + private readonly OidcLoginConfig _oidcLoginConfig; private readonly LoyaltyPointsSettings _loyaltyPointsSettings; private readonly PaymentSettings _paymentSettings; private readonly ShippingSettings _shippingSettings; @@ -871,7 +875,7 @@ private async Task CartValidate(IList cart) if (!cart.Any()) throw new Exception("Your cart is empty"); - if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && !_orderSettings.AnonymousCheckoutAllowed) + if (await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer) && !_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) throw new Exception("Anonymous checkout is not allowed"); } diff --git a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs index 1633fd25f..923eeb86c 100644 --- a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; using Grand.Business.Core.Interfaces.Checkout.Orders; @@ -13,6 +14,7 @@ using Grand.Domain.Customers; using Grand.Domain.Media; using Grand.Domain.Orders; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure; using Grand.SharedKernel.Attributes; using Grand.Web.Commands.Models.ShoppingCart; @@ -44,7 +46,8 @@ public ShoppingCartController( IMediator mediator, IShoppingCartValidator shoppingCartValidator, ShoppingCartSettings shoppingCartSettings, - OrderSettings orderSettings) + OrderSettings orderSettings, + OidcLoginConfig oidcLoginConfig) { _contextAccessor = contextAccessor; _shoppingCartService = shoppingCartService; @@ -58,6 +61,7 @@ public ShoppingCartController( _shoppingCartValidator = shoppingCartValidator; _shoppingCartSettings = shoppingCartSettings; _orderSettings = orderSettings; + _oidcLoginConfig = oidcLoginConfig; } #endregion @@ -92,6 +96,7 @@ private ShoppingCartType[] PrepareCartTypes() private readonly IShoppingCartValidator _shoppingCartValidator; private readonly ShoppingCartSettings _shoppingCartSettings; private readonly OrderSettings _orderSettings; + private readonly OidcLoginConfig _oidcLoginConfig; #endregion @@ -489,7 +494,7 @@ public virtual async Task StartCheckout(CheckoutAttributeSelected //everything is OK if (!await _groupService.IsGuest(_contextAccessor.WorkContext.CurrentCustomer)) return RedirectToRoute("Checkout"); - if (!_orderSettings.AnonymousCheckoutAllowed) + if (!_orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig)) return Challenge(); return RedirectToRoute("LoginCheckoutAsGuest", new { returnUrl = Url.RouteUrl("ShoppingCart") }); diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs index 4261e5d2e..58b959a18 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs @@ -13,6 +13,7 @@ using Grand.Domain.Media; using Grand.Domain.Orders; using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; using Grand.Web.Extensions; using Grand.Web.Features.Models.ShoppingCart; using Grand.Web.Models.Media; @@ -29,6 +30,7 @@ public class GetMiniShoppingCartHandler : IRequestHandler Handle(GetMiniShoppingCart request, ShowProductImages = _shoppingCartSettings.ShowImagesInsidebarCart, DisplayShoppingCartButton = true, CurrentCustomerIsGuest = await _groupService.IsGuest(request.Customer), - AnonymousCheckoutAllowed = _orderSettings.AnonymousCheckoutAllowed + AnonymousCheckoutAllowed = _orderSettings.AnonymousCheckoutAllowed(_oidcLoginConfig) }; if (!request.Customer.ShoppingCartItems.Any()) return model; diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs index 2a7fd36a2..d8b6582db 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs @@ -19,6 +19,7 @@ using Grand.Domain.Customers; using Grand.Domain.Media; using Grand.Domain.Orders; +using Grand.Infrastructure.Configuration; using Grand.Web.Common.Localization; using Grand.Web.Extensions; using Grand.Web.Features.Models.ShoppingCart; @@ -46,6 +47,7 @@ public class GetShoppingCartHandler : IRequestHandler>(SystemCustomerFieldNames.CheckoutAttributes, request.Store.Id); From 2566abd13bab086b3bb4fe4ca6d11d03219a568a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 14 Oct 2026 08:31:28 +0200 Subject: [PATCH 7/9] Provider-only mode: GrandNode passwords never expire, so no account is sent to a change-password page it cannot use --- .../GetPasswordIsExpiredQueryHandler.cs | 10 ++++- .../GetPasswordIsExpiredQueryHandlerTests.cs | 39 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Business/Grand.Business.Customers/Queries/Handlers/GetPasswordIsExpiredQueryHandler.cs b/src/Business/Grand.Business.Customers/Queries/Handlers/GetPasswordIsExpiredQueryHandler.cs index 723de0e2c..69f0d8200 100644 --- a/src/Business/Grand.Business.Customers/Queries/Handlers/GetPasswordIsExpiredQueryHandler.cs +++ b/src/Business/Grand.Business.Customers/Queries/Handlers/GetPasswordIsExpiredQueryHandler.cs @@ -1,6 +1,7 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Queries.Customers; using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; using Grand.Mediator; namespace Grand.Business.Customers.Queries.Handlers; @@ -9,11 +10,14 @@ public class GetPasswordIsExpiredQueryHandler : IRequestHandler Handle(GetPasswordIsExpiredQuery request, CancellationToken cancellationToken) @@ -21,6 +25,10 @@ public async Task Handle(GetPasswordIsExpiredQuery request, CancellationTo if (request.Customer == null) throw new ArgumentNullException(nameof(request.Customer)); + //in ProviderOnly the identity provider owns passwords: an expiry would send the account to a change-password page it cannot use + if (_loginConfig?.ProviderOnly == true) + return false; + //user without email don't have a password if (string.IsNullOrEmpty(request.Customer.Email)) return false; diff --git a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetPasswordIsExpiredQueryHandlerTests.cs b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetPasswordIsExpiredQueryHandlerTests.cs index 487145583..442a611f0 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetPasswordIsExpiredQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetPasswordIsExpiredQueryHandlerTests.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Queries.Customers; using Grand.Business.Customers.Queries.Handlers; using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -11,14 +12,16 @@ namespace Grand.Business.Customers.Tests.Queries.Handlers; public class GetPasswordIsExpiredQueryHandlerTests { private Mock _groupServiceMock; + private OidcLoginConfig _loginConfig; private GetPasswordIsExpiredQueryHandler handler; [TestInitialize] public void Init() { _groupServiceMock = new Mock(); + _loginConfig = new OidcLoginConfig(); handler = new GetPasswordIsExpiredQueryHandler(_groupServiceMock.Object, - new CustomerSettings { PasswordLifetime = 10 }); + new CustomerSettings { PasswordLifetime = 10 }, _loginConfig); } [TestMethod] @@ -39,4 +42,36 @@ public async Task HandleTest() //Assert Assert.IsTrue(result); } -} \ No newline at end of file + + [TestMethod] + public async Task Handle_ProviderOnly_ExpiredPassword_IsNotExpired() + { + //Arrange + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var cr = new CustomerGroup { Active = true, EnablePasswordLifetime = true }; + _groupServiceMock.Setup(c => c.GetAllByIds(It.IsAny())) + .Returns(Task.FromResult>(new List { cr })); + var customer = new Customer { Email = "email@email.com", PasswordChangeDateUtc = DateTime.UtcNow.AddDays(-30) }; + customer.Groups.Add(cr.Id); + //Act + var result = await handler.Handle(new GetPasswordIsExpiredQuery { Customer = customer }, CancellationToken.None); + //Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task Handle_Mixed_ExpiredPassword_IsExpired() + { + //Arrange + _loginConfig.SignInMode = OidcSignInMode.Mixed; + var cr = new CustomerGroup { Active = true, EnablePasswordLifetime = true }; + _groupServiceMock.Setup(c => c.GetAllByIds(It.IsAny())) + .Returns(Task.FromResult>(new List { cr })); + var customer = new Customer { Email = "email@email.com", PasswordChangeDateUtc = DateTime.UtcNow.AddDays(-30) }; + customer.Groups.Add(cr.Id); + //Act + var result = await handler.Handle(new GetPasswordIsExpiredQuery { Customer = customer }, CancellationToken.None); + //Assert + Assert.IsTrue(result); + } +} From f0cd95cbcc1e4d85d040cbb0bbddee846dd92df4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 14 Oct 2026 08:31:28 +0200 Subject: [PATCH 8/9] Provider-only mode: bearer tokens obtained with a password before the switch stop working at once (API users, Frontend API registered customers); guest tokens stay --- .../JwtBearerAuthenticationService.cs | 12 ++- .../JwtBearerCustomerAuthenticationService.cs | 17 +++- .../Services/ApiAuthenticationServiceTests.cs | 27 +++++- ...earerCustomerAuthenticationServiceTests.cs | 87 ++++++++++++++++++- 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs index 40c2df7ba..f646e5926 100644 --- a/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Customers; +using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.JwtBearer; #pragma warning disable CS0618 //API users stay supported until removed @@ -10,15 +11,17 @@ public class JwtBearerAuthenticationService : IJwtBearerAuthenticationService { private readonly ICustomerService _customerService; private readonly IUserApiService _userApiService; + private readonly OidcLoginConfig _oidcLoginConfig; private string _email; private string _errorMessage; public JwtBearerAuthenticationService( - ICustomerService customerService, IUserApiService userApiService) + ICustomerService customerService, IUserApiService userApiService, OidcLoginConfig oidcLoginConfig) { _customerService = customerService; _userApiService = userApiService; + _oidcLoginConfig = oidcLoginConfig; } /// @@ -29,6 +32,13 @@ public virtual async Task Valid(TokenValidatedContext context) { if (context.Principal == null) return await Task.FromResult(false); + //provider-only: API users are refused, tokens issued before the switch included + if (_oidcLoginConfig.ProviderOnly) + { + _errorMessage = "Sign-in with API users is disabled: use access tokens from the identity provider"; + return false; + } + _email = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Email")?.Value; var token = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Token")?.Value; if (string.IsNullOrEmpty(token)) diff --git a/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs index 9d980237c..4e8eaf311 100644 --- a/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs @@ -5,6 +5,7 @@ using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.JwtBearer; namespace Grand.Business.Authentication.Services; @@ -15,15 +16,20 @@ public class JwtBearerCustomerAuthenticationService : IJwtBearerCustomerAuthenti private readonly IGroupService _groupService; private readonly IPermissionService _permissionService; private readonly IRefreshTokenService _refreshTokenService; + private readonly IExternalSignInPolicy _externalSignInPolicy; + private readonly OidcLoginConfig _oidcLoginConfig; private string _errorMessage; public JwtBearerCustomerAuthenticationService(ICustomerService customerService, - IPermissionService permissionService, IGroupService groupService, IRefreshTokenService refreshTokenService) + IPermissionService permissionService, IGroupService groupService, IRefreshTokenService refreshTokenService, + IExternalSignInPolicy externalSignInPolicy, OidcLoginConfig oidcLoginConfig) { _customerService = customerService; _permissionService = permissionService; _groupService = groupService; _refreshTokenService = refreshTokenService; + _externalSignInPolicy = externalSignInPolicy; + _oidcLoginConfig = oidcLoginConfig; } public async Task Valid(TokenValidatedContext context) @@ -63,6 +69,15 @@ public async Task Valid(TokenValidatedContext context) return false; } + //provider-only: a token obtained with the password before the switch stops working at once, not at its + //expiry. Guests never had a password: their tokens stay + if (_oidcLoginConfig.ProviderOnly && !string.IsNullOrEmpty(customer.Email) && + !(await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.FrontendApi)).Allowed) + { + _errorMessage = "Sign in through the identity provider: tokens obtained with the password are not accepted"; + return false; + } + var refreshToken = await _refreshTokenService.GetCustomerRefreshToken(customer); if (refreshToken is null || string.IsNullOrEmpty(refreshId) || !refreshId.Equals(refreshToken.RefreshId)) { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs index dcb944e45..ccb889e23 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs @@ -27,6 +27,7 @@ public class ApiAuthenticationServiceTests private Mock _httpContextAccessorMoc; private IJwtBearerAuthenticationService _jwtBearerAuthenticationService; private Mock _userApiServiceMock; + private OidcLoginConfig _oidcLoginConfig; [TestInitialize] public void Init() @@ -37,8 +38,9 @@ public void Init() _groupService = new Mock(); _authService = new ApiAuthenticationService(_customerService.Object, _groupService.Object, _httpContextAccessorMoc.Object, new Mock().Object); + _oidcLoginConfig = new OidcLoginConfig(); _jwtBearerAuthenticationService = - new JwtBearerAuthenticationService(_customerService.Object, _userApiServiceMock.Object); + new JwtBearerAuthenticationService(_customerService.Object, _userApiServiceMock.Object, _oidcLoginConfig); } [TestMethod] @@ -171,6 +173,29 @@ public async Task Valid_ActiveUserApi_ReturnTrue() } + [TestMethod] + public async Task Valid_ProviderOnly_ActiveUserApi_ReturnFalse() + { + _oidcLoginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var context = new TokenValidatedContext(new Mock().Object, + new AuthenticationScheme("", "", typeof(AuthSchemaMock)), new JwtBearerOptions()); + IList claims = new List { + new("Email", "johny@gmail.com"), + new("Token", "123") + }; + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "")); + _customerService.Setup(c => c.GetCustomerByEmail(It.IsAny())) + .Returns(() => Task.FromResult(new Customer { Active = true })); + _userApiServiceMock.Setup(c => c.GetUserByEmail(It.IsAny())) + .Returns(() => Task.FromResult(new UserApi { IsActive = true, Token = "123" })); + + var result = await _jwtBearerAuthenticationService.Valid(context); + + Assert.IsFalse(result); + Assert.AreEqual("Sign-in with API users is disabled: use access tokens from the identity provider", + await _jwtBearerAuthenticationService.ErrorMessage()); + } + [TestMethod] public async Task GetAuthenticatedCustomer_NullAuthHeader_GetNull() { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs index 5d42643a6..dd4934922 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs @@ -7,6 +7,8 @@ using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Security; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; @@ -26,6 +28,8 @@ public class JwtBearerCustomerAuthenticationServiceTests private IJwtBearerCustomerAuthenticationService _jwtBearerCustomerAuthenticationService; private Mock _permissionServiceMock; private Mock _refreshTokenServiceMock; + private Mock _externalSignInPolicyMock; + private OidcLoginConfig _oidcLoginConfig; [TestInitialize] public void Init() @@ -35,9 +39,11 @@ public void Init() _permissionServiceMock = new Mock(); _httpContextMock = new Mock(); _refreshTokenServiceMock = new Mock(); + _externalSignInPolicyMock = new Mock(); + _oidcLoginConfig = new OidcLoginConfig(); _jwtBearerCustomerAuthenticationService = new JwtBearerCustomerAuthenticationService( _customerServiceMock.Object, _permissionServiceMock.Object, _groupService.Object, - _refreshTokenServiceMock.Object); + _refreshTokenServiceMock.Object, _externalSignInPolicyMock.Object, _oidcLoginConfig); } [TestMethod] @@ -182,4 +188,81 @@ public async Task Valid_Customer_ReturnTrue() var result = await _jwtBearerCustomerAuthenticationService.Valid(context); Assert.IsTrue(result); } -} \ No newline at end of file + + private TokenValidatedContext RegisteredCustomerContext(Customer customer) + { + customer.UserFields.Add(new UserField + { Key = SystemCustomerFieldNames.PasswordToken, Value = "123", StoreId = "" }); + _customerServiceMock.Setup(c => c.GetCustomerById(customer.Id)).ReturnsAsync(customer); + _refreshTokenServiceMock.Setup(c => c.GetCustomerRefreshToken(customer)).Returns(() => + Task.FromResult(new RefreshToken { IsActive = true, RefreshId = "567", Token = "123" })); + _permissionServiceMock.Setup(c => c.Authorize(StandardPermission.AllowUseApi, customer)).ReturnsAsync(true); + var context = new TokenValidatedContext(new Mock().Object, + new AuthenticationScheme("", "", typeof(AuthSchemaMock)), new JwtBearerOptions()); + IList claims = new List { + new("CustomerId", customer.Id), + new("Email", customer.Email), + new("Token", "123"), + new("RefreshId", "567") + }; + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "")); + return context; + } + + [TestMethod] + public async Task Valid_ProviderOnly_RegisteredCustomer_PasswordNotAllowed_ReturnFalse() + { + _oidcLoginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var customer = new Customer { Id = "cust-1", Email = "johny@gmail.com", Active = true }; + var context = RegisteredCustomerContext(customer); + _externalSignInPolicyMock.Setup(c => c.PasswordSignIn(customer, SignInSurface.FrontendApi)) + .ReturnsAsync(new PasswordSignInDecision(false, null)); + + var result = await _jwtBearerCustomerAuthenticationService.Valid(context); + + Assert.IsFalse(result); + Assert.AreEqual("Sign in through the identity provider: tokens obtained with the password are not accepted", + await _jwtBearerCustomerAuthenticationService.ErrorMessage()); + } + + [TestMethod] + public async Task Valid_Mixed_RegisteredCustomer_PolicyNotAsked_ReturnTrue() + { + var customer = new Customer { Id = "cust-1", Email = "johny@gmail.com", Active = true }; + var context = RegisteredCustomerContext(customer); + _externalSignInPolicyMock.Setup(c => c.PasswordSignIn(customer, SignInSurface.FrontendApi)) + .ReturnsAsync(new PasswordSignInDecision(false, null)); + + var result = await _jwtBearerCustomerAuthenticationService.Valid(context); + + Assert.IsTrue(result); + _externalSignInPolicyMock.Verify(c => c.PasswordSignIn(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Valid_ProviderOnly_Guest_ReturnTrue() + { + _oidcLoginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var guid = Guid.NewGuid(); + var guest = new Customer { CustomerGuid = guid, Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerByGuid(guid)).ReturnsAsync(guest); + _refreshTokenServiceMock.Setup(c => c.GetCustomerRefreshToken(guest)).Returns(() => + Task.FromResult(new RefreshToken { IsActive = true, RefreshId = "567", Token = "123" })); + _permissionServiceMock.Setup(c => c.Authorize(StandardPermission.AllowUseApi, guest)).ReturnsAsync(true); + _groupService.Setup(c => c.IsGuest(guest)).ReturnsAsync(true); + _externalSignInPolicyMock.Setup(c => c.PasswordSignIn(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PasswordSignInDecision(false, null)); + var context = new TokenValidatedContext(new Mock().Object, + new AuthenticationScheme("", "", typeof(AuthSchemaMock)), new JwtBearerOptions()); + IList claims = new List { + new("Guid", guid.ToString()), + new("RefreshId", "567") + }; + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "")); + + var result = await _jwtBearerCustomerAuthenticationService.Valid(context); + + Assert.IsTrue(result); + } +} From 2dbc8226ad336020e06eaa699770d76d4ac6806b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 14 Oct 2026 12:27:13 +0200 Subject: [PATCH 9/9] Provider-only mode: OidcLogin:AutoProvision creates the account of an unknown provider user at the first sign-in, without the registration form --- .../Services/OidcCustomerResolver.cs | 58 ++++++++++++++++--- .../Configuration/OidcLoginConfig.cs | 7 +++ .../OidcCustomerResolverLoginTests.cs | 57 ++++++++++++++++++ src/Web/Grand.Web/App_Data/appsettings.json | 2 + 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index b2b16691c..9ae0e05dc 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -174,6 +174,10 @@ public virtual async Task ResolveForLogin(string sub, string em _customerConfig.RegisterCustomersPerStore ? storeId : ""); if (customer == null) { + //provider-only with automatic accounts: the provider decides who gets in, no form and no registration type + if (_loginConfig is { ProviderOnly: true, AutoProvision: true }) + return await ProvisionForLogin(sub, email, emailVerified, storeId); + var customerSettings = await _settingService.LoadSetting(storeId); if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled) { @@ -259,13 +263,53 @@ private async Task Register(string sub, string email, string if (existing != null) return await Usable(await _customerService.GetCustomerById(existing.CustomerId), sub, storeId); + var (outcome, customer, winner) = await CreateLinkedAccount(sub, email, storeId); + return outcome switch { + Provisioning.UserNameTaken => OidcCustomerResult.Forbidden("The user name is already taken"), + Provisioning.LinkedToOtherIdentity => LinkedToOtherIdentity(customer, sub), + Provisioning.LostRace => await Usable(winner, sub, storeId), + _ => OidcCustomerResult.Success(customer) + }; + } + + /// + /// ProviderOnly + AutoProvision: the account of an unknown, provider-verified e-mail, created and linked at its first + /// browser sign-in (the profile sync of the sign-in fills in the names) + /// + private async Task ProvisionForLogin(string sub, string email, bool emailVerified, string storeId) + { + var (outcome, customer, _) = await CreateLinkedAccount(sub, email, storeId); + return outcome switch { + Provisioning.UserNameTaken => OidcLoginResult.Refused(OidcLoginRefusal.AccountUnusable), + Provisioning.LinkedToOtherIdentity => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity), + //another request of the same user created the account first: its link answers now + Provisioning.LostRace => await ResolveForLogin(sub, email, emailVerified, storeId), + _ => OidcLoginResult.SignIn(customer) + }; + } + + private enum Provisioning + { + Created, + UserNameTaken, + LinkedToOtherIdentity, + LostRace + } + + /// + /// Creates an active account with the Registered group for the subject and links it (not by e-mail). The store's + /// e-mail validation / admin approval does not apply: access comes from the provider and from permissions an admin grants + /// + private async Task<(Provisioning Outcome, Customer Customer, Customer Winner)> CreateLinkedAccount(string sub, + string email, string storeId) + { //the e-mail becomes the user name - another account may hold it as its user name without that e-mail var normalizedEmail = email.ToLowerInvariant(); if (await _customerService.GetCustomerByUsername(normalizedEmail, _customerConfig.RegisterCustomersPerStore ? storeId : "") != null) { - _logger.LogWarning("Access token subject {Subject}: the user name is already taken in store {StoreId}", sub, storeId); - return OidcCustomerResult.Forbidden("The user name is already taken"); + _logger.LogWarning("Subject {Subject}: the user name is already taken in store {StoreId}", sub, storeId); + return (Provisioning.UserNameTaken, null, null); } var customer = await _customerService.InsertGuestCustomer(new Customer { @@ -275,8 +319,6 @@ private async Task Register(string sub, string email, string LastActivityDateUtc = DateTime.UtcNow }); - //access comes from permissions an admin grants (and the provider's e-mail check is configurable), so the store's e-mail - //validation / admin approval does not apply; the account gets the Registered group only await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, normalizedEmail, normalizedEmail, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, storeId)); @@ -285,14 +327,16 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, { //lost the race: drop the account just created and use the one the winner linked await _customerService.DeleteCustomer(customer, true); - return other != null ? LinkedToOtherIdentity(customer, sub) : await Usable(linked, sub, storeId); + return other != null + ? (Provisioning.LinkedToOtherIdentity, customer, null) + : (Provisioning.LostRace, null, linked); } //only after the link is stored, so the loser of a race sends no welcome message await _mediator.Publish(new CustomerRegisteredEvent(customer)); - _logger.LogInformation("Access token subject {Subject} registered as customer {CustomerId} (store {StoreId})", + _logger.LogInformation("Subject {Subject} registered as customer {CustomerId} (store {StoreId})", sub, customer.Id, storeId); - return OidcCustomerResult.Success(customer); + return (Provisioning.Created, customer, null); } /// diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs index a1dbe49fc..0bd753d11 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs @@ -46,6 +46,13 @@ public class OidcLoginConfig /// public OidcSignInMode SignInMode { get; set; } = OidcSignInMode.Mixed; + /// + /// ProviderOnly: the first provider sign-in of an unknown (provider-verified) e-mail creates the account at once, with the + /// Registered group and no registration form, whatever the store's registration type: the provider decides who gets in. + /// False: the registration form opens (and a store with registration disabled refuses) + /// + public bool AutoProvision { get; set; } + /// E-mails of accounts that keep password sign-in and recovery on the Admin login page in ProviderOnly public string[] BreakGlassAccounts { get; set; } = []; diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs index 3e79fa33d..d26286326 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -288,6 +288,63 @@ public async Task ProviderOnly_VerifiedEmail_Unknown_RegistrationRequired() (await Resolver().ResolveForLogin("sub-new", "new@shop.com", true, Store)).Outcome); } + [TestMethod] + [DataRow(UserRegistrationType.Standard)] + [DataRow(UserRegistrationType.Disabled)] + [DataRow(UserRegistrationType.AdminApproval)] + public async Task ProviderOnly_AutoProvision_Unknown_AccountCreatedLinkedAndSignIn(UserRegistrationType type) + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _loginConfig.AutoProvision = true; + _customerSettings.UserRegistrationType = type; + + var result = await Resolver().ResolveForLogin("sub-new", "New@Shop.com", true, Store); + + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreEqual("new1", result.Customer.Id); + Assert.IsTrue(result.Customer.Active); + _customerManagerService.Verify(x => x.RegisterCustomer(It.Is(r => + r.Email == "new@shop.com" && r.Username == "new@shop.com")), Times.Once); + var link = LinksOf("sub-new").Single(); + Assert.AreEqual("new1", link.CustomerId); + Assert.IsFalse(link.LinkedByEmail); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task ProviderOnly_AutoProvision_UnverifiedEmail_RefusedNothingCreated() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _loginConfig.AutoProvision = true; + + Assert.AreEqual(OidcLoginRefusal.EmailNotVerified, + (await Resolver().ResolveForLogin("sub-new", "new@shop.com", false, Store)).Refusal); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProviderOnly_AutoProvision_UserNameTaken_RefusedNothingCreated() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _loginConfig.AutoProvision = true; + _customerService.Setup(x => x.GetCustomerByUsername("new@shop.com", It.IsAny())) + .ReturnsAsync(new Customer { Id = "other" }); + + Assert.AreEqual(OidcLoginRefusal.AccountUnusable, + (await Resolver().ResolveForLogin("sub-new", "new@shop.com", true, Store)).Refusal); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Mixed_AutoProvisionIgnored_RegistrationRequired() + { + _loginConfig.AutoProvision = true; + + Assert.AreEqual(OidcLoginOutcome.RegistrationRequired, + (await Resolver().ResolveForLogin("sub-new", "new@shop.com", true, Store)).Outcome); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + [TestMethod] public async Task ProviderOnly_LinkMadeByEmail_StaffSignsIn() { diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 9da6f17dc..caf0e98b4 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -291,6 +291,8 @@ "SsoOnly": false, //Mixed (default) or ProviderOnly: the identity provider is the only way to get an account and to sign in (storefront, panels, APIs) "SignInMode": "Mixed", + //ProviderOnly: true creates the account of an unknown user (verified e-mail) at the first provider sign-in, without the registration form and whatever the store's registration type + "AutoProvision": false, //ProviderOnly: e-mails that keep the password on the Admin login page (/admin/login?password=1); every such sign-in is logged "BreakGlassAccounts": [] },