diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs
index bea8d2e451..96132773bc 100644
--- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs
+++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs
@@ -28,6 +28,7 @@ public class CookieAuthenticationService : IGrandAuthenticationService
/// HTTP context accessor
/// SecurityConfig
/// Server-side sessions of provider sign-ins
+ /// Accounts that must sign in through an external provider
/// Logger
public CookieAuthenticationService(
CustomerSettings customerSettings,
@@ -37,6 +38,7 @@ public CookieAuthenticationService(
ICookieOptionsFactory cookieOptionsFactory,
SecurityConfig securityConfig,
IOidcSessionService oidcSessionService,
+ IExternalSignInPolicy externalSignInPolicy,
ILogger logger)
{
_customerSettings = customerSettings;
@@ -46,6 +48,7 @@ public CookieAuthenticationService(
_cookieOptionsFactory = cookieOptionsFactory;
_securityConfig = securityConfig;
_oidcSessionService = oidcSessionService;
+ _externalSignInPolicy = externalSignInPolicy;
_logger = logger;
}
@@ -71,6 +74,7 @@ public CookieAuthenticationService(
private readonly SecurityConfig _securityConfig;
private readonly IOidcSessionService _oidcSessionService;
+ private readonly IExternalSignInPolicy _externalSignInPolicy;
private readonly ILogger _logger;
#endregion
@@ -275,10 +279,12 @@ private async Task IsValidCustomer(Customer customer, ClaimsPrincipal prin
//a provider sign-in: the server-side session decides (ended, idle, refused by the provider)
var oidcSessionId = OidcSessionId(principal);
- if (!string.IsNullOrEmpty(oidcSessionId) && !await _oidcSessionService.Validate(oidcSessionId, customer))
- return false;
+ if (!string.IsNullOrEmpty(oidcSessionId))
+ return await _oidcSessionService.Validate(oidcSessionId, customer);
- return true;
+ //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;
}
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
new file mode 100644
index 0000000000..5d9469196c
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/OidcSsoSignInPolicy.cs
@@ -0,0 +1,37 @@
+using Grand.Business.Core.Extensions;
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Configuration;
+
+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
+///
+public class OidcSsoSignInPolicy(
+ IRepository externalAuthenticationRepository,
+ IEnumerable externalAuthenticationProviders,
+ ExternalAuthenticationSettings externalAuthenticationSettings,
+ OidcLoginConfig config,
+ OidcResourceServerConfig resourceServerConfig) : IExternalSignInPolicy
+{
+ public async Task RequiredProvider(Customer customer)
+ {
+ if (customer == null || !config.SsoOnly || !config.IsConfigured(resourceServerConfig))
+ return null;
+
+ var provider = externalAuthenticationProviders.FirstOrDefault(x =>
+ x.SystemName.Equals(OidcCustomerResolver.ProviderSystemName, StringComparison.OrdinalIgnoreCase));
+ if (provider == null || !provider.IsMethodActive(externalAuthenticationSettings))
+ return null;
+
+ var confirmed = await externalAuthenticationRepository.GetOneAsync(x =>
+ x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.CustomerId == customer.Id &&
+ !x.LinkedByEmail);
+ return confirmed != null ? provider : null;
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
index e03aee9535..b4712bc785 100644
--- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
+++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
@@ -25,6 +25,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
}
public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment)
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPathProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPathProvider.cs
new file mode 100644
index 0000000000..2f4877e1c3
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPathProvider.cs
@@ -0,0 +1,14 @@
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// An external authentication provider that signs in through its own page: offered on the panels' login pages, and
+/// used to send the browser through the provider once more after a registration that connected the identity
+///
+public interface IExternalSignInPathProvider : IExternalAuthenticationProvider
+{
+ ///
+ /// Path (from the application root, starting with "/") that signs in and returns to ;
+ /// null when the provider cannot sign in now (for example, not configured)
+ ///
+ Task GetSignInPath(string returnUrl);
+}
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs
new file mode 100644
index 0000000000..277ee07be8
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignInPolicy.cs
@@ -0,0 +1,12 @@
+using Grand.Domain.Customers;
+
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// Accounts that must sign in with an external authentication provider instead of their password
+///
+public interface IExternalSignInPolicy
+{
+ /// The provider the account must sign in with; null when its password may be used
+ Task RequiredProvider(Customer customer);
+}
diff --git a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs
index 7768df188f..46722c0f4e 100644
--- a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs
+++ b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs
@@ -1,4 +1,5 @@
using Grand.Business.Core.Extensions;
+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;
@@ -27,14 +28,17 @@ public class CustomerManagerService : ICustomerManagerService
/// Mediator
/// History password
/// Customer settings
+ /// Accounts that must sign in with an external provider
public CustomerManagerService(
ICustomerService customerService,
IGroupService groupService,
IEncryptionService encryptionService,
IMediator mediator,
ICustomerHistoryPasswordService customerHistoryPasswordService,
- CustomerSettings customerSettings)
+ CustomerSettings customerSettings,
+ IExternalSignInPolicy externalSignInPolicy)
{
+ _externalSignInPolicy = externalSignInPolicy;
_customerService = customerService;
_groupService = groupService;
_encryptionService = encryptionService;
@@ -53,6 +57,7 @@ public CustomerManagerService(
private readonly IMediator _mediator;
private readonly ICustomerHistoryPasswordService _customerHistoryPasswordService;
private readonly CustomerSettings _customerSettings;
+ private readonly IExternalSignInPolicy _externalSignInPolicy;
#endregion
@@ -84,6 +89,10 @@ public virtual async Task LoginCustomer(string usernameOrE
if (!isValid)
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)
+ return CustomerLoginResults.ExternalSignInRequired;
+
//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/Core/Grand.Domain/Customers/CustomerLoginResults.cs b/src/Core/Grand.Domain/Customers/CustomerLoginResults.cs
index 0da942742a..b0190af522 100644
--- a/src/Core/Grand.Domain/Customers/CustomerLoginResults.cs
+++ b/src/Core/Grand.Domain/Customers/CustomerLoginResults.cs
@@ -43,5 +43,10 @@ public enum CustomerLoginResults
///
/// Requires 2fa
///
- RequiresTwoFactor = 8
+ RequiresTwoFactor = 8,
+
+ ///
+ /// The account signs in only with an external authentication provider
+ ///
+ ExternalSignInRequired = 9
}
\ No newline at end of file
diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs
index 352d148778..26fc30576b 100644
--- a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs
+++ b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs
@@ -27,10 +27,11 @@ public class OidcLoginConfig
public int RevalidationMinutes { get; set; } = DefaultRevalidationMinutes;
///
- /// Back-office accounts connected to the identity provider with their password sign in only through the provider;
- /// an account without such a connection keeps its password (break-glass)
+ /// Accounts connected to the identity provider with their password sign in only through the provider (storefront,
+ /// panels, Frontend API) and get no password recovery e-mail; an account without such a connection keeps its
+ /// password (break-glass)
///
- public bool StaffSsoOnly { get; set; }
+ public bool SsoOnly { get; set; }
//zero or less is a configuration mistake, never "check on every request" or "end every session at once"
public TimeSpan StaffIdleTimeout =>
diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs
index 788aae87c2..3332500fa8 100644
--- a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs
+++ b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs
@@ -28,4 +28,10 @@ public class OidcProtectedResource
public bool DenyVendors { get; set; }
public bool DenyStoreManagers { get; set; }
+
+ ///
+ /// A token is accepted at most this many minutes after it was issued (iat), whatever its expiry; empty = no limit.
+ /// Bounds how long a token outlives the user's removal at the identity provider
+ ///
+ public int? MaxTokenAgeMinutes { get; set; }
}
diff --git a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs
index 5f03284bd2..049cfa810e 100644
--- a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs
+++ b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs
@@ -27,6 +27,7 @@ public class TokenWebController : ControllerBase
private readonly ICustomerService _customerService;
private readonly IMediator _mediator;
private readonly IRefreshTokenService _refreshTokenService;
+ private readonly IExternalSignInPolicy _externalSignInPolicy;
private readonly IContextAccessor _contextAccessor;
public TokenWebController(
@@ -34,6 +35,7 @@ public TokenWebController(
IMediator mediator,
IContextAccessor contextAccessor,
IRefreshTokenService refreshTokenService,
+ IExternalSignInPolicy externalSignInPolicy,
IAntiforgery antiforgery,
FrontendAPIConfig apiConfig,
CustomerConfig customerConfig)
@@ -42,6 +44,7 @@ public TokenWebController(
_mediator = mediator;
_contextAccessor = contextAccessor;
_refreshTokenService = refreshTokenService;
+ _externalSignInPolicy = externalSignInPolicy;
_antiforgery = antiforgery;
_apiConfig = apiConfig;
_customerConfig = customerConfig;
@@ -143,6 +146,11 @@ public async Task Refresh([FromBody] TokenDto tokenDto)
if (customer == null)
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.RequiredProvider(customer) != null)
+ return BadRequest("Invalid refresh token");
+
//rebuild the claims from the resolved customer (registered carry id/email/token, guests carry the guid)
if (!string.IsNullOrEmpty(customer.Email))
{
diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs
index 2cce9a04a4..84442bf640 100644
--- a/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs
+++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs
@@ -12,6 +12,7 @@ public static class OidcLoginDefaults
public const string ConfigSection = "OidcLogin";
public const string HttpClientName = "Grand.OidcLogin";
public const string BackChannelLogoutPath = "oidc/backchannel-logout";
+ public const string LoginPath = "/oidc/login";
public const string FailedPath = "/oidc/failed";
public const string ConfigurationUrl = "../OidcLoginSettings/Configure";
public static readonly TimeSpan PendingLifetime = TimeSpan.FromMinutes(15);
diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs
index 34c757dd39..7813fc7540 100644
--- a/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs
+++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs
@@ -1,8 +1,10 @@
using Grand.Business.Core.Interfaces.Authentication;
+using Microsoft.AspNetCore.Authentication;
namespace Authentication.OpenIdConnect;
-public class OidcLoginProvider(OidcLoginSettings settings) : IExternalAuthenticationProvider
+public class OidcLoginProvider(OidcLoginSettings settings, IAuthenticationSchemeProvider authenticationSchemeProvider)
+ : IExternalSignInPathProvider
{
public string ConfigurationUrl => OidcLoginDefaults.ConfigurationUrl;
@@ -20,4 +22,9 @@ public class OidcLoginProvider(OidcLoginSettings settings) : IExternalAuthentica
/// Gets a view component for displaying plugin in public store
///
public Task GetPublicViewComponentName() => Task.FromResult("OidcLogin");
+
+ public async Task GetSignInPath(string returnUrl) =>
+ await authenticationSchemeProvider.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme) == null
+ ? null
+ : OidcLoginDefaults.LoginPath + "?returnUrl=" + Uri.EscapeDataString(returnUrl ?? "/");
}
diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs
index 09fb202606..f7d5a02f63 100644
--- a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs
+++ b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs
@@ -29,6 +29,7 @@ public class CookieAuthenticationServiceTests
private Mock serviceProviderMock;
private CookieOptionsFactory _cookieOptionsFactory;
private Mock _oidcSessionService;
+ private Mock _signInPolicy;
[TestInitialize]
public void Init()
@@ -44,9 +45,10 @@ 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, NullLogger.Instance);
+ _oidcSessionService.Object, _signInPolicy.Object, NullLogger.Instance);
//For mock HttpContext extension methods like SignOutAsync ,SignInAsync etc..
_authServiceMock = new Mock();
serviceProviderMock = new Mock();
@@ -210,6 +212,39 @@ public async Task GetAuthenticatedCustomer_NoSessionClaim_SessionServiceNotAsked
_oidcSessionService.Verify(x => x.Validate(It.IsAny(), It.IsAny()), Times.Never);
}
+ //an account that must sign in through the provider: a cookie without a session (issued before the account was
+ //connected or before SsoOnly) would escape revalidation and back-channel logout
+ [TestMethod]
+ 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());
+ Assert.IsNull(await _cookieAuthService.GetAuthenticatedCustomer());
+ }
+
+ [TestMethod]
+ public async Task GetAuthenticatedCustomer_NoSessionClaim_PasswordAllowed_ReturnCustomer()
+ {
+ var customer = RegisteredCustomer();
+ _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Ticket());
+ _signInPolicy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync((IExternalAuthenticationProvider)null);
+ Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer());
+ _signInPolicy.Verify(x => x.RequiredProvider(customer), Times.Once);
+ }
+
+ //a session cookie is decided by its session; the policy is not asked
+ [TestMethod]
+ public async Task GetAuthenticatedCustomer_SessionClaim_PolicyNotAsked()
+ {
+ var customer = RegisteredCustomer();
+ _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny()))
+ .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);
+ }
+
[TestMethod]
public async Task GetAuthenticatedCustomer_EndedSession_Null()
{
diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs
index bc46d7403e..11701dc862 100644
--- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs
+++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs
@@ -14,7 +14,7 @@ public void Defaults()
Assert.AreEqual(TimeSpan.FromMinutes(30), config.StaffIdleTimeout);
Assert.AreEqual(TimeSpan.FromHours(10), config.StaffMaxSession);
Assert.AreEqual(TimeSpan.FromMinutes(10), config.RevalidationInterval);
- Assert.IsFalse(config.StaffSsoOnly);
+ Assert.IsFalse(config.SsoOnly);
}
//a zero in appsettings must not mean "refresh at the provider on every request" or "end every session at once"
diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs
new file mode 100644
index 0000000000..406d95cd36
--- /dev/null
+++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSsoSignInPolicyTests.cs
@@ -0,0 +1,95 @@
+using Grand.Business.Authentication.Services;
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Data;
+using Grand.Data.Tests.MongoDb;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Configuration;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Business.Authentication.Tests.Services;
+
+[TestClass]
+public class OidcSsoSignInPolicyTests
+{
+ private IRepository _links;
+ private Mock _provider;
+ private ExternalAuthenticationSettings _settings;
+ private OidcLoginConfig _config;
+ private OidcResourceServerConfig _resourceServer;
+ private readonly Customer _customer = new() { Id = "c1" };
+
+ [TestInitialize]
+ public void Init()
+ {
+ _links = new MongoDBRepositoryTest();
+ _provider = new Mock();
+ _provider.Setup(x => x.SystemName).Returns(OidcCustomerResolver.ProviderSystemName);
+ _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect");
+ _settings = new ExternalAuthenticationSettings { ActiveAuthenticationMethodSystemNames = [OidcCustomerResolver.ProviderSystemName] };
+ _config = new OidcLoginConfig { ClientId = "shop", SsoOnly = true };
+ _resourceServer = new OidcResourceServerConfig { Authority = "https://idp" };
+ }
+
+ private OidcSsoSignInPolicy Policy(params IExternalAuthenticationProvider[] providers) =>
+ new(_links, providers.Length > 0 ? providers : [_provider.Object], _settings, _config, _resourceServer);
+
+ private Task Link(bool byEmail) => _links.InsertAsync(new ExternalAuthentication {
+ CustomerId = "c1", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1",
+ LinkedByEmail = byEmail
+ });
+
+ //every account with a confirmed link, not only back-office ones
+ [TestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public async Task ConfirmedLink_ProviderRequired_WhateverTheAccount(bool staff)
+ {
+ await Link(byEmail: false);
+ Assert.AreSame(_provider.Object, await Policy().RequiredProvider(_customer));
+ }
+
+ //break-glass: an account never connected to the provider keeps its password
+ [TestMethod]
+ public async Task WithoutLink_PasswordAllowed() => Assert.IsNull(await Policy().RequiredProvider(_customer));
+
+ //"connect your account" needs the password
+ [TestMethod]
+ public async Task LinkedByEmail_PasswordAllowed()
+ {
+ await Link(byEmail: true);
+ Assert.IsNull(await Policy().RequiredProvider(_customer));
+ }
+
+ [TestMethod]
+ public async Task OptionOff_PasswordAllowed()
+ {
+ _config.SsoOnly = false;
+ await Link(byEmail: false);
+ Assert.IsNull(await Policy().RequiredProvider(_customer));
+ }
+
+ [TestMethod]
+ public async Task MethodInactive_PasswordAllowed()
+ {
+ _settings.ActiveAuthenticationMethodSystemNames = [];
+ await Link(byEmail: false);
+ Assert.IsNull(await Policy().RequiredProvider(_customer));
+ }
+
+ [TestMethod]
+ public async Task PluginNotInstalled_PasswordAllowed()
+ {
+ await Link(byEmail: false);
+ Assert.IsNull(await new OidcSsoSignInPolicy(_links, [], _settings, _config, _resourceServer)
+ .RequiredProvider(_customer));
+ }
+
+ [TestMethod]
+ public async Task NotConfigured_PasswordAllowed()
+ {
+ _config.ClientId = "";
+ await Link(byEmail: false);
+ Assert.IsNull(await Policy().RequiredProvider(_customer));
+ }
+}
diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs
index c9137a7e30..7374fb55da 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.Common.Directory;
+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;
using Grand.Business.Core.Utilities.Customers;
@@ -21,6 +22,7 @@ public class CustomerManagerServiceTests
private Mock _encryptionServiceMock;
private Mock _groupServiceMock;
private Mock _mediatorMock;
+ private Mock _signInPolicy;
[TestInitialize]
public void Init()
@@ -35,12 +37,34 @@ public void Init()
entered == stored);
_mediatorMock = new Mock();
_customerHistoryPasswordServiceMock = new Mock();
+ _signInPolicy = new Mock();
_customerSettings = new CustomerSettings {
AllowUsersToChangeUsernames = true
};
_customerManagerService = new CustomerManagerService(_customerServiceMock.Object, _groupServiceMock.Object,
- _encryptionServiceMock.Object, _mediatorMock.Object, _customerHistoryPasswordServiceMock.Object, _customerSettings);
+ _encryptionServiceMock.Object, _mediatorMock.Object, _customerHistoryPasswordServiceMock.Object, _customerSettings,
+ _signInPolicy.Object);
+ }
+
+ [TestMethod]
+ 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);
+ Assert.AreEqual(CustomerLoginResults.ExternalSignInRequired,
+ await _customerManagerService.LoginCustomer("admin@admin.com", "123456"));
+ }
+
+ //a wrong password says nothing about how the account signs in
+ [TestMethod]
+ 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);
}
[TestMethod]
diff --git a/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs b/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs
index a18c59d787..dad5705549 100644
--- a/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs
+++ b/src/Tests/Grand.Module.Api.Tests/Controllers/TokenWebControllerTests.cs
@@ -26,6 +26,7 @@ public class TokenWebControllerTests
private Mock _customerServiceMock;
private Mock _mediatorMock;
private Mock _refreshTokenServiceMock;
+ private Mock _signInPolicyMock;
private GenerateTokenWebCommand _capturedCommand;
[TestInitialize]
@@ -34,6 +35,7 @@ public void Init()
_customerServiceMock = new Mock();
_mediatorMock = new Mock();
_refreshTokenServiceMock = new Mock();
+ _signInPolicyMock = new Mock();
_refreshTokenServiceMock.Setup(r => r.GenerateRefreshToken()).Returns("rt");
_refreshTokenServiceMock.Setup(r => r.SaveRefreshTokenToCustomer(It.IsAny(), It.IsAny()))
@@ -52,7 +54,7 @@ private TokenWebController CreateController(bool perStore)
return new TokenWebController(
_customerServiceMock.Object, _mediatorMock.Object, contextAccessor.Object,
- _refreshTokenServiceMock.Object, new Mock().Object,
+ _refreshTokenServiceMock.Object, _signInPolicyMock.Object, new Mock().Object,
new FrontendAPIConfig { Enabled = true },
new CustomerConfig { RegisterCustomersPerStore = perStore });
}
@@ -102,4 +104,45 @@ public async Task Refresh_WithCustomerIdClaim_ResolvesById()
_customerServiceMock.Verify(c => c.GetCustomerById("c1"), Times.Once);
_customerServiceMock.Verify(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never);
}
+
+ private Customer RefreshableCustomer()
+ {
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("CustomerId", "c1") }));
+ _refreshTokenServiceMock.Setup(r => r.GetPrincipalFromToken("access")).Returns(principal);
+ var customer = new Customer { Id = "c1", Email = "u@x.com" };
+ _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(customer);
+ _refreshTokenServiceMock.Setup(r => r.GetCustomerRefreshToken(customer))
+ .ReturnsAsync(new RefreshToken { Token = "rt", ValidTo = DateTime.UtcNow.AddDays(1), RefreshId = "rid" });
+ return customer;
+ }
+
+ //a token pair obtained with the password before the account had to use the provider is not renewed: a user
+ //disabled at the provider would otherwise keep the API
+ [TestMethod]
+ public async Task Refresh_ProviderRequired_InvalidRefreshToken()
+ {
+ var customer = RefreshableCustomer();
+ _signInPolicyMock.Setup(p => p.RequiredProvider(customer)).ReturnsAsync(Mock.Of());
+ var controller = CreateController(perStore: false);
+
+ var result = await controller.Refresh(new TokenDto { AccessToken = "access", RefreshToken = "rt" });
+
+ Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult));
+ Assert.AreEqual("Invalid refresh token", ((BadRequestObjectResult)result).Value);
+ _refreshTokenServiceMock.Verify(r => r.SaveRefreshTokenToCustomer(It.IsAny(), It.IsAny()), Times.Never);
+ _mediatorMock.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task Refresh_PasswordAllowed_Ok()
+ {
+ var customer = RefreshableCustomer();
+ _signInPolicyMock.Setup(p => p.RequiredProvider(customer)).ReturnsAsync((IExternalAuthenticationProvider)null);
+ 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);
+ }
}
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 3ff4d2d96c..511d602e32 100644
--- a/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginWebValidatorTests.cs
+++ b/src/Tests/Grand.Module.Api.Tests/Validators/Common/LoginWebValidatorTests.cs
@@ -86,6 +86,20 @@ public async Task PerStoreOff_UsesGlobalLookup()
_customerManagerServiceMock.Verify(m => m.LoginCustomer(It.IsAny(), It.IsAny(), ""), Times.Once);
}
+ [TestMethod]
+ 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()))
+ .ReturnsAsync(CustomerLoginResults.ExternalSignInRequired);
+
+ var validator = CreateValidator(apiEnabled: true, perStore: true);
+ var result = await validator.ValidateAsync(ValidModel());
+
+ Assert.IsFalse(result.IsValid);
+ }
+
[TestMethod]
public async Task WrongCredentials_Fails()
{
diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs
index 71281eeed9..1d1ac71660 100644
--- a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs
+++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs
@@ -1,20 +1,39 @@
using Authentication.OpenIdConnect;
using Grand.Infrastructure.Configuration;
using Grand.Web.Common.Security.Oidc;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
namespace Grand.Modules.Tests.Plugins.OpenIdConnect;
[TestClass]
public class OidcLoginProviderTests
{
+ [TestMethod]
+ public async Task PanelSignInPath_WithScheme()
+ {
+ var schemes = new Mock();
+ schemes.Setup(x => x.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme)).ReturnsAsync(
+ new AuthenticationScheme(OidcLoginDefaults.AuthenticationScheme, null, typeof(IAuthenticationHandler)));
+ var provider = new OidcLoginProvider(new OidcLoginSettings(), schemes.Object);
+ Assert.AreEqual("/oidc/login?returnUrl=%2Fadmin", await provider.GetSignInPath("/admin"));
+ }
+
+ [TestMethod]
+ public async Task PanelSignInPath_NoScheme_Null()
+ {
+ var provider = new OidcLoginProvider(new OidcLoginSettings(), new Mock().Object);
+ Assert.IsNull(await provider.GetSignInPath("/admin"));
+ }
+
[TestMethod]
public void SystemName_IsTheLinkName() =>
- Assert.AreEqual(OidcResourceServerDefaults.ProviderSystemName, new OidcLoginProvider(new OidcLoginSettings()).SystemName);
+ Assert.AreEqual(OidcResourceServerDefaults.ProviderSystemName, new OidcLoginProvider(new OidcLoginSettings(), new Mock().Object).SystemName);
[TestMethod]
public void Priority_FromSettings() =>
- Assert.AreEqual(7, new OidcLoginProvider(new OidcLoginSettings { DisplayOrder = 7 }).Priority);
+ Assert.AreEqual(7, new OidcLoginProvider(new OidcLoginSettings { DisplayOrder = 7 }, new Mock().Object).Priority);
[TestMethod]
[DataRow("https://idp", "client", true)]
diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs
new file mode 100644
index 0000000000..c95be90e43
--- /dev/null
+++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs
@@ -0,0 +1,72 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Localization;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Business.Core.Interfaces.Messages;
+using Grand.Domain.Common;
+using Grand.Domain.Customers;
+using Grand.Infrastructure;
+using Grand.Mediator;
+using Grand.Web.Admin.Controllers;
+using Grand.Web.AdminShared.Models.Common;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Web.Admin.Tests.Controllers;
+
+[TestClass]
+public class LoginControllerExternalSignInTests
+{
+ private Mock _externalAuthenticationService = null!;
+ private LoginController _controller = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _externalAuthenticationService = new Mock();
+ _controller = new LoginController(new CustomerSettings(), new CaptchaSettings(),
+ new Mock().Object, new Mock().Object,
+ new Mock().Object, new Mock().Object,
+ new Mock().Object, new Mock().Object,
+ new Mock().Object);
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.PathBase = "/shop";
+ _controller.ControllerContext = new ControllerContext {
+ HttpContext = httpContext,
+ RouteData = new RouteData { Values = { ["area"] = "Admin" } }
+ };
+ var url = new Mock();
+ url.Setup(x => x.RouteUrl(It.Is(c => c.RouteName == "AdminIndex"))).Returns("/shop/admin");
+ _controller.Url = url.Object;
+ }
+
+ [TestMethod]
+ public async Task Index_ListsPanelProvidersOnly()
+ {
+ var panel = new Mock();
+ panel.Setup(x => x.FriendlyName).Returns("OpenID Connect");
+ panel.Setup(x => x.GetSignInPath("/shop/admin")).ReturnsAsync("/oidc/login?returnUrl=%2Fshop%2Fadmin");
+ var storefrontOnly = new Mock();
+ _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null))
+ .Returns([panel.Object, storefrontOnly.Object]);
+
+ var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object)).Model!;
+
+ Assert.AreEqual(1, model.ExternalSignIn.Count);
+ Assert.AreEqual("OpenID Connect", model.ExternalSignIn[0].Name);
+ Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fshop%2Fadmin", model.ExternalSignIn[0].Url);
+ }
+
+ [TestMethod]
+ public async Task Index_ProviderWithoutPath_Hidden()
+ {
+ var panel = new Mock();
+ panel.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync((string)null!);
+ _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)).Returns([panel.Object]);
+ var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object)).Model!;
+ Assert.AreEqual(0, model.ExternalSignIn.Count);
+ }
+}
diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/PanelPasswordRecoveryServiceTests.cs
index 3b7e8d11cc..194b5b3a59 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.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Common.Directory;
using Grand.Business.Core.Interfaces.Common.Localization;
using Grand.Business.Core.Interfaces.Common.Security;
@@ -30,6 +31,7 @@ public class PanelPasswordRecoveryServiceTests
private Mock _permissionService;
private Mock _vendorService;
private Mock _messageProviderService;
+ private Mock _signInPolicy;
private CustomerSettings _customerSettings;
private Customer _customer;
private PanelPasswordRecoveryService _service;
@@ -47,6 +49,7 @@ public void Init()
_permissionService = new Mock();
_vendorService = new Mock();
_messageProviderService = new Mock();
+ _signInPolicy = new Mock();
var translationService = new Mock();
translationService.Setup(x => x.GetResource(It.IsAny())).Returns((string key) => key);
@@ -63,7 +66,8 @@ public void Init()
_service = new PanelPasswordRecoveryService(_customerService.Object, _customerManagerService.Object,
_historyService.Object, _groupService.Object, _permissionService.Object, _vendorService.Object,
- _messageProviderService.Object, translationService.Object, contextAccessor.Object, _customerSettings);
+ _messageProviderService.Object, translationService.Object, contextAccessor.Object, _customerSettings,
+ _signInPolicy.Object);
}
private void GrantAdminPanel()
@@ -120,6 +124,20 @@ public async Task SendRecoveryMessage_AdminOfAdminPanel_StoresTokenAndArea_Sends
It.Is(s => s.Id == "store"), "lang"), Times.Once);
}
+ [TestMethod]
+ public async Task SendRecoveryMessage_ExternalSignInRequired_NoMessage()
+ {
+ GrantAdminPanel();
+ _signInPolicy.Setup(x => x.RequiredProvider(_customer))
+ .ReturnsAsync(new Mock().Object);
+
+ await _service.SendRecoveryMessage(Email, "Admin");
+
+ VerifyMessageSent(Times.Never());
+ _customerService.Verify(x => x.UpdateUserField(It.IsAny(), SystemCustomerFieldNames.PasswordRecoveryToken,
+ It.IsAny(), It.IsAny()), Times.Never);
+ }
+
[TestMethod]
public async Task SendRecoveryMessage_UnknownEmail_SendsNothing()
{
@@ -260,6 +278,23 @@ public async Task ResetPassword_WrongToken_DoesNotChangePassword()
It.IsAny()), Times.Never);
}
+ //a link sent before the account had to sign in through the provider sets no password any more
+ [TestMethod]
+ public async Task ResetPassword_ExternalSignInRequired_WrongToken_DoesNotChangePassword()
+ {
+ GrantAdminPanel();
+ GiveToken(DateTime.UtcNow);
+ _signInPolicy.Setup(x => x.RequiredProvider(_customer))
+ .ReturnsAsync(new Mock().Object);
+
+ Assert.AreEqual("Account.PasswordRecovery.WrongToken", await _service.ValidateToken(Email, Token, "Admin"));
+ var result = await _service.ResetPassword(Email, Token, "Admin", "N3w-password");
+
+ Assert.AreEqual("Account.PasswordRecovery.WrongToken", result);
+ _customerManagerService.Verify(x => x.ChangePassword(It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
[TestMethod]
public async Task ResetPassword_PreviousPassword_Rejected()
{
diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs
new file mode 100644
index 0000000000..f042be3420
--- /dev/null
+++ b/src/Tests/Grand.Web.Admin.Tests/Validators/LoginValidatorExternalSignInTests.cs
@@ -0,0 +1,89 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Common.Localization;
+using Grand.Business.Core.Interfaces.Common.Security;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Domain.Common;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Models;
+using Grand.Infrastructure.Validators;
+using Grand.Mediator;
+using Grand.SharedKernel.Captcha;
+using Grand.Web.AdminShared.Models.Common;
+using Grand.Web.AdminShared.Validators.Common;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Web.Admin.Tests.Validators;
+
+[TestClass]
+public class LoginValidatorExternalSignInTests
+{
+ private readonly Customer _customer = new() { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "pw" };
+ private Mock _customerService;
+ private Mock _encryption;
+ private Mock _groups;
+ private Mock _policy;
+ private Mock _provider;
+ private Mock _translation;
+
+ [TestInitialize]
+ public void Init()
+ {
+ _policy = new Mock();
+ _provider = new Mock();
+ _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect");
+ _customerService = new Mock();
+ _customerService.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(_customer);
+ _groups = new Mock();
+ _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true);
+ _encryption = new Mock();
+ _encryption.Setup(e => e.VerifyPassword(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .Returns((string entered, PasswordFormat _, string stored, string _, HashedPasswordFormat _) =>
+ entered == stored);
+ _translation = new Mock();
+ _translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k);
+ _translation.Setup(t => t.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"))
+ .Returns("Sign in with {0}");
+ }
+
+ private LoginValidator Validator()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new Mock().Object);
+ var http = new Mock();
+ http.Setup(a => a.HttpContext).Returns(new DefaultHttpContext { RequestServices = services.BuildServiceProvider() });
+
+ return new LoginValidator(
+ new List>(),
+ new List>(),
+ _customerService.Object, _groups.Object, _encryption.Object, _translation.Object, new CustomerSettings(),
+ new CaptchaSettings(), http.Object, new Mock().Object, _policy.Object);
+ }
+
+ [TestMethod]
+ public async Task CorrectPassword_ExternalSignInRequired_Failure()
+ {
+ _policy.Setup(x => x.RequiredProvider(_customer)).ReturnsAsync(_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]
+ public async Task CorrectPassword_NoPolicy_Valid()
+ {
+ var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" });
+ Assert.IsTrue(result.IsValid);
+ }
+
+ [TestMethod]
+ 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);
+ }
+}
diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs
index a7ba60200f..9124eac1ac 100644
--- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs
@@ -1,4 +1,4 @@
-using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Common.Security;
using Grand.Business.Core.Utilities.Authentication;
using Grand.Domain.Customers;
@@ -24,7 +24,7 @@ public class OidcCustomerPipelineTests
private Mock _resolver;
private Mock _permissions;
- private async Task Start(OidcResourceServerConfig config = null)
+ private async Task Start(OidcResourceServerConfig config = null, Action extra = null)
{
if (_host != null) await _host.DisposeAsync();
_host = new OidcTestHost();
@@ -33,6 +33,7 @@ private async Task Start(OidcResourceServerConfig config = null)
{
s.AddSingleton(_resolver.Object);
s.AddSingleton(_permissions.Object);
+ extra?.Invoke(s);
},
endpoints: e =>
{
@@ -128,4 +129,39 @@ public async Task RequestCustomerResolver_WithAndWithoutToken()
using var withoutToken = await Get("/api/plain");
Assert.AreEqual("none", await withoutToken.Content.ReadAsStringAsync());
}
+
+ private static OidcResourceServerConfig ConfigWithMaxAge(int? minutes) => OidcTestHost.Config(new OidcProtectedResource {
+ Name = "BackendApi", PathPrefix = "/api", ScopesSupported = ["grandnode.api"], RequiredScopes = ["grandnode.api"],
+ MaxTokenAgeMinutes = minutes
+ });
+
+ [TestMethod]
+ public async Task TokenOlderThanMaxAge_401_ResolverNeverCalled()
+ {
+ await Start(ConfigWithMaxAge(10));
+ using var response = await Get("/api/whoami", OidcTestHost.Token(issuedAt: DateTime.UtcNow.AddMinutes(-15)));
+ Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
+ StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "The token is too old");
+ _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task TokenWithinMaxAge_200()
+ {
+ await Start(ConfigWithMaxAge(10));
+ using var response = await Get("/api/whoami", OidcTestHost.Token(issuedAt: DateTime.UtcNow.AddMinutes(-2)));
+ Assert.AreEqual("c1", await response.Content.ReadAsStringAsync());
+ }
+
+ [TestMethod]
+ public async Task RevokedSubject_401_ResolverNeverCalled()
+ {
+ var revocation = new Mock();
+ revocation.Setup(x => x.IsRevoked("sub-1", It.IsAny())).ReturnsAsync(true);
+ await Start(extra: s => s.AddSingleton(revocation.Object));
+ using var response = await Get("/api/whoami", OidcTestHost.Token());
+ Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
+ StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "The token was revoked");
+ _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never);
+ }
}
diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs
index c411149ec3..c85d59d195 100644
--- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs
@@ -1,4 +1,4 @@
-using Grand.Infrastructure.Configuration;
+using Grand.Infrastructure.Configuration;
using Grand.Web.Common.Security.Oidc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -112,4 +112,14 @@ public void EmptyClaimName()
config.Claims.Subject = "";
Assert.AreEqual(1, Validate(config).Count);
}
+
+ [TestMethod]
+ [DataRow(0)]
+ [DataRow(-1)]
+ public void MaxTokenAgeMinutes_NotPositive_Error(int minutes)
+ {
+ var config = Valid();
+ config.Resources[0].MaxTokenAgeMinutes = minutes;
+ Assert.IsTrue(Validate(config).Any(e => e.Contains("MaxTokenAgeMinutes")));
+ }
}
diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs
index 9d70dfa06a..43411f5eee 100644
--- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs
@@ -42,7 +42,7 @@ public sealed class OidcTestHost : IAsyncDisposable
public static string Token(string audience = "http://localhost/api", string scope = "grandnode.api",
SecurityKey key = null, string algorithm = SecurityAlgorithms.RsaSha256, DateTime? expires = null,
- string issuer = Issuer, params Claim[] extra)
+ string issuer = Issuer, DateTime? issuedAt = null, params Claim[] extra)
{
var claims = new List {
new("sub", "sub-1"), new("email", "john@example.com"), new("email_verified", "true", ClaimValueTypes.Boolean)
@@ -55,7 +55,7 @@ public static string Token(string audience = "http://localhost/api", string scop
Audience = audience,
Subject = new ClaimsIdentity(claims),
NotBefore = exp.AddMinutes(-20),
- IssuedAt = exp.AddMinutes(-20),
+ IssuedAt = issuedAt ?? exp.AddMinutes(-20),
Expires = exp,
SigningCredentials = new SigningCredentials(key ?? IdpKey, algorithm)
});
@@ -107,6 +107,7 @@ public async Task Start(OidcResourceServerConfig config,
defaultResolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
.ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true }));
s.TryAddSingleton(defaultResolver.Object);
+ s.TryAddSingleton(new Mock().Object);
var permissions = new Mock();
permissions.Setup(x => x.Authorize(It.IsAny(), It.IsAny())).ReturnsAsync(true);
s.TryAddSingleton(permissions.Object);
diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs
index 23950db3c4..c26792b1e3 100644
--- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs
@@ -1,4 +1,4 @@
-using Grand.Web.Common.Security.Oidc;
+using Grand.Web.Common.Security.Oidc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Security.Claims;
@@ -57,4 +57,22 @@ public void Scopes_MissingReported_CaseSensitive_NullRequiredIsEmpty()
[TestMethod]
public void PublicScopes_DropsOfflineAccessBlanksDuplicates() =>
CollectionAssert.AreEqual(new[] { "a", "b" }, OidcTokenChecks.PublicScopes(["a", "offline_access", " ", "b", "a"]));
+
+ [TestMethod]
+ public void IsTooOld_NoLimit_False() =>
+ Assert.IsFalse(OidcTokenChecks.IsTooOld(DateTime.UtcNow.AddDays(-1), null, 60, DateTime.UtcNow));
+
+ [TestMethod]
+ public void IsTooOld_NoIat_True() =>
+ Assert.IsTrue(OidcTokenChecks.IsTooOld(DateTime.MinValue, 10, 60, DateTime.UtcNow));
+
+ [TestMethod]
+ [DataRow(10, false)]
+ [DataRow(11, false)]
+ [DataRow(12, true)]
+ public void IsTooOld_LimitPlusSkew(int ageMinutes, bool expected)
+ {
+ var now = DateTime.UtcNow;
+ Assert.AreEqual(expected, OidcTokenChecks.IsTooOld(now.AddMinutes(-ageMinutes), 10, 60, now));
+ }
}
diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs
new file mode 100644
index 0000000000..01676b3e6b
--- /dev/null
+++ b/src/Tests/Grand.Web.Tests/Commands/Customers/PasswordRecoverySendCommandHandlerTests.cs
@@ -0,0 +1,59 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Business.Core.Interfaces.Messages;
+using Grand.Domain.Customers;
+using Grand.Domain.Localization;
+using Grand.Web.Commands.Handler.Customers;
+using Grand.Web.Commands.Models.Customers;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Web.Tests.Commands.Customers;
+
+[TestClass]
+public class PasswordRecoverySendCommandHandlerTests
+{
+ [TestMethod]
+ public async Task ExternalSignInRequired_NoMessage_NoToken()
+ {
+ var customerService = new Mock();
+ var messages = new Mock();
+ var policy = new Mock();
+ var customer = new Customer { Id = "c1" };
+ policy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync(new Mock().Object);
+ var handler = new PasswordRecoverySendCommandHandler(customerService.Object, messages.Object, policy.Object);
+
+ Assert.IsTrue(await handler.Handle(new PasswordRecoverySendCommand {
+ Customer = customer, Store = new Grand.Domain.Stores.Store(), Language = new Language { Id = "l1" }
+ }, CancellationToken.None));
+
+ messages.Verify(x => x.SendCustomerPasswordRecoveryMessage(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ customerService.Verify(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task NullCustomer_NoMessage()
+ {
+ var messages = new Mock();
+ var handler = new PasswordRecoverySendCommandHandler(new Mock().Object, messages.Object,
+ new Mock().Object);
+
+ Assert.IsTrue(await handler.Handle(new PasswordRecoverySendCommand {
+ Store = new Grand.Domain.Stores.Store(), Language = new Language { Id = "l1" }
+ }, CancellationToken.None));
+
+ messages.Verify(x => x.SendCustomerPasswordRecoveryMessage(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task PasswordAllowed_MessageSent()
+ {
+ var messages = new Mock();
+ var handler = new PasswordRecoverySendCommandHandler(new Mock().Object, messages.Object,
+ new Mock().Object);
+ await handler.Handle(new PasswordRecoverySendCommand {
+ Customer = new Customer { Id = "c1" }, Store = new Grand.Domain.Stores.Store(), Language = new Language { Id = "l1" }
+ }, CancellationToken.None);
+ messages.Verify(x => x.SendCustomerPasswordRecoveryMessage(It.IsAny(), It.IsAny(), "l1"), Times.Once);
+ }
+}
diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs
index ac97cd44db..41afa9a643 100644
--- a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs
+++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs
@@ -69,7 +69,9 @@ public void Init()
_controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
_controller.TempData = new TempDataDictionary(_controller.HttpContext, new Mock().Object);
var url = new Mock();
- url.Setup(x => x.RouteUrl(It.IsAny())).Returns("/registerresult/1");
+ url.Setup(x => x.RouteUrl(It.IsAny())).Returns("https://localhost/registerresult/1");
+ url.Setup(x => x.IsLocalUrl(It.IsAny())).Returns((string u) =>
+ !string.IsNullOrEmpty(u) && u.StartsWith('/') && !u.StartsWith("//"));
_controller.Url = url.Object;
}
@@ -166,4 +168,74 @@ public async Task Post_NoPending_RegularPath_NoLink()
Assert.AreEqual("Secret1!", request.Password);
_external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never);
}
+
+ private Mock PathProvider(string path = "/oidc/login?returnUrl=%2Fregisterresult%2F1")
+ {
+ var provider = new Mock();
+ provider.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync(path);
+ _externalAuth.Setup(x => x.LoadAuthenticationProviderBySystemName("ExternalAuth.OpenIdConnect")).Returns(provider.Object);
+ return provider;
+ }
+
+ [TestMethod]
+ public async Task Post_Pending_LinkedAndSignedIn_GoesThroughProviderSignIn()
+ {
+ _settings.UserRegistrationType = UserRegistrationType.Standard;
+ _external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
+ _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked);
+ var provider = PathProvider();
+
+ var result = (RedirectResult)await _controller.Register(new RegisterModel(), "/cart", _external.Object, _externalAuth.Object);
+
+ Assert.AreEqual("/oidc/login?returnUrl=%2Fregisterresult%2F1", result.Url);
+ provider.Verify(x => x.GetSignInPath(It.Is(u =>
+ u.StartsWith("/") && u.Contains("/registerresult/1") && u.Contains("returnurl=%2Fcart"))), Times.Once);
+ }
+
+ [TestMethod]
+ public async Task Post_Pending_NotApproved_NoProviderRedirect()
+ {
+ _settings.UserRegistrationType = UserRegistrationType.AdminApproval;
+ _external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
+ _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked);
+ var provider = PathProvider();
+ var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object);
+ Assert.IsInstanceOfType(result, typeof(RedirectToRouteResult));
+ provider.Verify(x => x.GetSignInPath(It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task Post_Pending_NotLinked_NoProviderRedirect()
+ {
+ _settings.UserRegistrationType = UserRegistrationType.Standard;
+ _external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
+ _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer);
+ var provider = PathProvider();
+ var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object);
+ Assert.IsFalse(result.Url.Contains("/oidc/login"));
+ provider.Verify(x => x.GetSignInPath(It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task Post_Pending_ProviderWithoutSignInPath_NoProviderRedirect()
+ {
+ _settings.UserRegistrationType = UserRegistrationType.Standard;
+ _external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
+ _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked);
+ _externalAuth.Setup(x => x.LoadAuthenticationProviderBySystemName("ExternalAuth.OpenIdConnect"))
+ .Returns(new Mock().Object);
+ var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object);
+ Assert.AreEqual("https://localhost/registerresult/1", result.Url);
+ }
+
+ [TestMethod]
+ public async Task Post_Pending_NullSignInPath_NoProviderRedirect()
+ {
+ _settings.UserRegistrationType = UserRegistrationType.Standard;
+ _external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
+ _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked);
+ PathProvider(null);
+ var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object);
+ Assert.AreEqual("https://localhost/registerresult/1", result.Url);
+ }
}
diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs
index 63b1949eca..3a7c8dc16b 100644
--- a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs
+++ b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs
@@ -65,7 +65,7 @@ public async Task LoginValidator_ScopesEmailLookupToCurrentStore()
customerService.Object, new Mock().Object, new Mock().Object,
Translation().Object, new CustomerSettings(), new Grand.Domain.Common.CaptchaSettings(),
new Mock().Object, new Mock().Object,
- Context(), PerStoreOn().Config);
+ Context(), PerStoreOn().Config, new Mock().Object);
await validator.ValidateAsync(new LoginModel { Email = "u@x.com", Password = "p" });
@@ -123,7 +123,8 @@ public async Task PasswordRecoveryConfirmValidator_ScopesEmailLookupToCurrentSto
new List>(),
customerService.Object, new Mock().Object,
new Mock().Object, new Mock().Object,
- Translation().Object, new CustomerSettings(), Context(), PerStoreOn().Config);
+ Translation().Object, new CustomerSettings(), Context(), PerStoreOn().Config,
+ new Mock().Object);
await validator.ValidateAsync(new PasswordRecoveryConfirmModel {
Email = "u@x.com", NewPassword = "abc", ConfirmNewPassword = "abc"
diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs
new file mode 100644
index 0000000000..c3d84e61dc
--- /dev/null
+++ b/src/Tests/Grand.Web.Tests/Validators/Customer/LoginValidatorExternalSignInTests.cs
@@ -0,0 +1,96 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Common.Localization;
+using Grand.Business.Core.Interfaces.Common.Security;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Domain.Common;
+using Grand.Domain.Customers;
+using Grand.Infrastructure;
+using Grand.Infrastructure.Configuration;
+using Grand.Infrastructure.Models;
+using Grand.Infrastructure.Validators;
+using Grand.Mediator;
+using Grand.SharedKernel.Captcha;
+using Grand.Web.Models.Customer;
+using Grand.Web.Validators.Customer;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Web.Tests.Validators.Customers;
+
+[TestClass]
+public class LoginValidatorExternalSignInTests
+{
+ private readonly Customer _customer = new() { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "pw" };
+ private Mock _customerService;
+ private Mock _encryption;
+ private Mock _groups;
+ private Mock _policy;
+ private Mock _provider;
+ private Mock _translation;
+
+ [TestInitialize]
+ public void Init()
+ {
+ _policy = new Mock();
+ _provider = new Mock();
+ _provider.Setup(x => x.FriendlyName).Returns("OpenID Connect");
+ _customerService = new Mock();
+ _customerService.Setup(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(_customer);
+ _groups = new Mock();
+ _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true);
+ _encryption = new Mock();
+ _encryption.Setup(e => e.VerifyPassword(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .Returns((string entered, PasswordFormat _, string stored, string _, HashedPasswordFormat _) =>
+ entered == stored);
+ _translation = new Mock();
+ _translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k);
+ _translation.Setup(t => t.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"))
+ .Returns("Sign in with {0}");
+ }
+
+ private LoginValidator Validator()
+ {
+ 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 store = new Mock();
+ store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "store-1" });
+ var ctx = new Mock();
+ ctx.Setup(c => c.StoreContext).Returns(store.Object);
+
+ return new LoginValidator(
+ new List>(),
+ new List>(),
+ _customerService.Object, _groups.Object, _encryption.Object, _translation.Object, new CustomerSettings(),
+ new CaptchaSettings(), http.Object, new Mock().Object,
+ ctx.Object, new CustomerConfig(), _policy.Object);
+ }
+
+ [TestMethod]
+ public async Task CorrectPassword_ExternalSignInRequired_Failure()
+ {
+ _policy.Setup(x => x.RequiredProvider(_customer)).ReturnsAsync(_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]
+ public async Task CorrectPassword_NoPolicy_Valid()
+ {
+ var result = await Validator().ValidateAsync(new LoginModel { Email = "a@b.c", Password = "pw" });
+ Assert.IsTrue(result.IsValid);
+ }
+
+ [TestMethod]
+ 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);
+ }
+}
diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs
new file mode 100644
index 0000000000..589a134130
--- /dev/null
+++ b/src/Tests/Grand.Web.Tests/Validators/Customer/PasswordRecoveryConfirmValidatorExternalSignInTests.cs
@@ -0,0 +1,76 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Common.Localization;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Domain.Common;
+using Grand.Domain.Customers;
+using Grand.Infrastructure;
+using Grand.Infrastructure.Configuration;
+using Grand.Infrastructure.Validators;
+using Grand.Web.Models.Customer;
+using Grand.Web.Validators.Customer;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Web.Tests.Validators.Customers;
+
+[TestClass]
+public class PasswordRecoveryConfirmValidatorExternalSignInTests
+{
+ private const string Token = "token-1";
+
+ private Customer _customer;
+ private Mock _signInPolicy;
+ private PasswordRecoveryConfirmValidator _validator;
+
+ [TestInitialize]
+ public void Init()
+ {
+ _customer = new Customer { Id = "c1", Email = "u@x.com", Active = true };
+ _customer.UserFields.Add(new UserField
+ { Key = SystemCustomerFieldNames.PasswordRecoveryToken, Value = Token, StoreId = "" });
+ _customer.UserFields.Add(new UserField {
+ Key = SystemCustomerFieldNames.PasswordRecoveryTokenDateGenerated,
+ Value = DateTime.UtcNow.ToString("o"), StoreId = ""
+ });
+
+ var customerService = new Mock();
+ customerService.Setup(x => x.GetCustomerByEmail("u@x.com", It.IsAny())).ReturnsAsync(_customer);
+ var groupService = new Mock();
+ groupService.Setup(x => x.IsRegistered(_customer)).ReturnsAsync(true);
+ var translation = new Mock();
+ translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k);
+ _signInPolicy = new Mock();
+
+ _validator = new PasswordRecoveryConfirmValidator(
+ new List>(),
+ customerService.Object, groupService.Object,
+ new Mock().Object, new Mock().Object,
+ translation.Object, new CustomerSettings(), new Mock().Object, new CustomerConfig(),
+ _signInPolicy.Object);
+ }
+
+ private Task Validate() =>
+ _validator.ValidateAsync(new PasswordRecoveryConfirmModel {
+ Email = "u@x.com", Token = Token, NewPassword = "N3w-password", ConfirmNewPassword = "N3w-password"
+ });
+
+ [TestMethod]
+ public async Task ValidLink_Valid()
+ {
+ Assert.IsTrue((await Validate()).IsValid);
+ }
+
+ //a link sent before the account had to sign in through the provider sets no password any more
+ [TestMethod]
+ public async Task ExternalSignInRequired_WrongToken()
+ {
+ _signInPolicy.Setup(x => x.RequiredProvider(_customer))
+ .ReturnsAsync(new Mock().Object);
+
+ var result = await Validate();
+
+ Assert.IsFalse(result.IsValid);
+ Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.PasswordRecovery.WrongToken"));
+ }
+}
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml
index 56bc5f039e..1b8dab7d35 100644
--- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml
+++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml
@@ -63,6 +63,12 @@
}
+ @foreach (var signIn in Model.ExternalSignIn)
+ {
+
+ @Loc["Admin.Login.SignInWith", signIn.Name]
+
+ }
\ No newline at end of file
diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs
index 4f72a2c971..d13607611a 100644
--- a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs
+++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs
@@ -53,18 +53,20 @@ protected virtual string GetCurrentArea()
return RouteData.Values["area"]?.ToString() ?? "Admin";
}
- public IActionResult Index()
+ public async Task Index([FromServices] IExternalAuthenticationService externalAuthenticationService)
{
var model = new LoginModel {
UsernamesEnabled = _customerSettings.UsernamesEnabled,
DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage
};
+ await PrepareExternalSignIn(model, externalAuthenticationService);
return View(model);
}
[HttpPost]
[AutoValidateAntiforgeryToken]
- public virtual async Task Index(LoginModel model)
+ public virtual async Task Index(LoginModel model,
+ [FromServices] IExternalAuthenticationService externalAuthenticationService)
{
if (ModelState.IsValid)
{
@@ -93,10 +95,26 @@ await _customerManagerService.LoginCustomer(
//If we got this far, something failed, redisplay form
model.UsernamesEnabled = _customerSettings.UsernamesEnabled;
model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage;
+ await PrepareExternalSignIn(model, externalAuthenticationService);
return View(model);
}
+ //the panel's home is where the provider sign-in returns; only providers made for the panels are offered
+ private async Task PrepareExternalSignIn(LoginModel model, IExternalAuthenticationService externalAuthenticationService)
+ {
+ var returnUrl = Url.RouteUrl($"{GetCurrentArea()}Index");
+ foreach (var provider in externalAuthenticationService.LoadActiveAuthenticationProviders()
+ .OfType