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) + { + + } \ 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()) + { + var path = await provider.GetSignInPath(returnUrl); + if (!string.IsNullOrEmpty(path)) + model.ExternalSignIn.Add(new LoginModel.ExternalSignInButton { + Name = provider.FriendlyName, Url = Request.PathBase + path + }); + } + } + #region Password recovery public IActionResult PasswordRecovery() diff --git a/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs b/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs index f96f6695f7..42094a7c25 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs @@ -24,4 +24,12 @@ public class LoginModel : BaseModel public bool DisplayCaptcha { get; set; } public ICaptchaValidModel Captcha { get; set; } = new CaptchaModel(); + + public IList ExternalSignIn { get; set; } = new List(); + + public class ExternalSignInButton + { + public string Name { get; set; } + public string Url { get; set; } + } } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs b/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs index 838bc7ffc7..c418b52c50 100644 --- a/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/PanelPasswordRecoveryService.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; @@ -21,7 +22,8 @@ public class PanelPasswordRecoveryService( IMessageProviderService messageProviderService, ITranslationService translationService, IContextAccessor contextAccessor, - CustomerSettings customerSettings) : IPanelPasswordRecoveryService + CustomerSettings customerSettings, + IExternalSignInPolicy externalSignInPolicy) : IPanelPasswordRecoveryService { public const string AreaAdmin = "Admin"; public const string AreaStore = "Store"; @@ -104,6 +106,10 @@ private async Task GetPanelCustomer(string email, string area) return null; if (!await groupService.IsRegistered(customer)) 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) + 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 ba09dd1a5c..23cab5114c 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using Grand.Business.Core.Events.Customers; +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; @@ -24,7 +25,8 @@ public LoginValidator( IEnumerable> validatorsCaptcha, ICustomerService customerService, IGroupService groupService, IEncryptionService encryptionService, ITranslationService translationService, CustomerSettings customerSettings, CaptchaSettings captchaSettings, - IHttpContextAccessor contextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator) + IHttpContextAccessor contextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator, + IExternalSignInPolicy externalSignInPolicy) : base(validators) { if (!customerSettings.UsernamesEnabled) @@ -79,6 +81,12 @@ public LoginValidator( await contextAccessor.HttpContext!.RequestServices.GetRequiredService() .Publish(new CustomerLoginFailedEvent(customer), _); } + else if (await externalSignInPolicy.RequiredProvider(customer) is { } provider) + { + context.AddFailure(string.Format( + translationService.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"), + provider.FriendlyName)); + } break; } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index 2c2e16defb..28b67bc196 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Grand.Web.Common.Security.Oidc; @@ -54,6 +55,22 @@ public override async Task TokenValidated(TokenValidatedContext context) return; } + //JsonWebTokenHandler is the default handler: IssuedAt is MinValue when the token has no iat + var issuedUtc = (context.SecurityToken as JsonWebToken)?.IssuedAt ?? DateTime.MinValue; + if (OidcTokenChecks.IsTooOld(issuedUtc, resource.MaxTokenAgeMinutes, _config.ClockSkewSeconds, DateTime.UtcNow)) + { + Reject(context, OidcFailureKind.InvalidToken, "The token is too old"); + return; + } + + //the provider ended the subject's session (back-channel logout) or refused the account after this token was issued + var subject = principal.FindFirst(_config.Claims.Subject)?.Value; + if (await http.RequestServices.GetRequiredService().IsRevoked(subject, issuedUtc)) + { + Reject(context, OidcFailureKind.InvalidToken, "The token was revoked"); + return; + } + http.Items[OidcResourceServerDefaults.ResourceItemKey] = resource; if (!await AfterTokenChecks(context, resource) && OidcAuthenticationFailure.Get(http) == null) diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs index cabeb3685f..ddb8aac7b6 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs @@ -41,6 +41,10 @@ public static IList Validate(OidcResourceServerConfig config, IOidcProte .Where(r => !string.IsNullOrWhiteSpace(r.Resource) && !IsResourceUri(r.Resource)) .Select(r => $"Protected resource '{r.Name}': Resource must be an absolute http(s) URI without a fragment.")); + errors.AddRange(registry.Resources + .Where(r => r.MaxTokenAgeMinutes is <= 0) + .Select(r => $"Resource {r.Name}: MaxTokenAgeMinutes must be greater than 0 or empty")); + return errors; } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs index 323422c7ed..6ab20da32e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs @@ -44,6 +44,18 @@ public static IList MissingScopes(ClaimsPrincipal principal, IEnumerable return requiredList.Where(s => !granted.Contains(s)).ToList(); } + /// + /// The token is older than the resource accepts (or has no issue time while a limit is set) + /// + public static bool IsTooOld(DateTime issuedUtc, int? maxTokenAgeMinutes, int clockSkewSeconds, DateTime now) + { + if (maxTokenAgeMinutes is not > 0) + return false; + if (issuedUtc == DateTime.MinValue) + return true; + return now - issuedUtc > TimeSpan.FromMinutes(maxTokenAgeMinutes.Value) + TimeSpan.FromSeconds(clockSkewSeconds); + } + /// /// Scopes that may be advertised or required: distinct, non-empty, never offline_access /// diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Login/Index.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Login/Index.cshtml index d2152c2c48..dbd3ad3d6c 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Login/Index.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Login/Index.cshtml @@ -63,6 +63,12 @@ } + @foreach (var signIn in Model.ExternalSignIn) + { + + } \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Login/Index.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Login/Index.cshtml index 58e0dabd7d..5e8c03fea6 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Login/Index.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Login/Index.cshtml @@ -63,6 +63,12 @@ } + @foreach (var signIn in Model.ExternalSignIn) + { + + } \ No newline at end of file diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index b820881cb8..c917139c6e 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index 744f2dde2a..b6dc2b5dee 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -15,4 +15,10 @@ The account was created, but signing in with your external account could not be connected to it. Sign in with your e-mail (use "Forgot password" to set a password). + + This account signs in with {0}. Use the {0} button. + + + Sign in with {0} + diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 92da6a2d67..c099a0e85c 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -265,6 +265,8 @@ "AuthorizedParties": [], "ScopesSupported": [ "grandnode.api" ], "RequiredScopes": [ "grandnode.api" ], + //a provider token is accepted at most 10 minutes after it was issued, so it cannot outlive the user's removal at the provider + "MaxTokenAgeMinutes": 10, "RequireAdminPanelAccess": true, //the Web API's controllers apply no vendor or store scoping: store-wide data, so main administrators only "DenyVendors": true, @@ -285,8 +287,8 @@ //back-office accounts only (admin panel, store staff, vendors) signed in through the provider "StaffIdleTimeoutMinutes": 30, "StaffMaxSessionHours": 10, - //true: back-office accounts connected to the provider sign in only through it; accounts without a connection keep the password - "StaffSsoOnly": false + //true: accounts connected to the provider (with their password once) sign in only through it; accounts without a connection keep the password + "SsoOnly": false }, "Logging": { "LogLevel": { diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs index b280d23958..6f3147e1a8 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/PasswordRecoverySendCommandHandler.cs @@ -1,4 +1,5 @@ -using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Messages; using Grand.Domain.Customers; using Grand.Web.Commands.Models.Customers; @@ -10,17 +11,24 @@ public class PasswordRecoverySendCommandHandler : IRequestHandler 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) + return true; + //save token and current date var passwordRecoveryToken = Guid.NewGuid(); await _customerService.UpdateUserField(request.Customer, SystemCustomerFieldNames.PasswordRecoveryToken, diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 3d8540af6c..7ded4d7101 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -506,12 +506,14 @@ public virtual async Task Register(RegisterModel model, string re await _customerManagerService.RegisterCustomer(registrationRequest); //connect the provider identity before anything can sign the customer in + var connected = false; if (connects) { var linkResult = await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id); //the account is still the customer's (verified e-mail), only signing in with the provider is not connected - if (linkResult != OidcLinkResult.Linked) + connected = linkResult == OidcLinkResult.Linked; + if (!connected) Warning(_translationService.GetResource("Account.Register.External.NotConnected")); } else if (pending != null) @@ -536,6 +538,7 @@ await _mediator.Send(new CustomerRegisteredCommand { //raise event await _mediator.Publish(new CustomerRegisteredEvent(_contextAccessor.WorkContext.CurrentCustomer)); + IActionResult result; switch (registrationType) { case UserRegistrationType.EmailValidation: @@ -548,13 +551,15 @@ await _messageProviderService.SendCustomerEmailValidationMessage( _contextAccessor.WorkContext.WorkingLanguage.Id); //result - return RedirectToRoute("RegisterResult", + result = RedirectToRoute("RegisterResult", new { resultId = (int)UserRegistrationType.EmailValidation }); + break; } case UserRegistrationType.AdminApproval: { - return RedirectToRoute("RegisterResult", + result = RedirectToRoute("RegisterResult", new { resultId = (int)UserRegistrationType.AdminApproval }); + break; } case UserRegistrationType.Standard: { @@ -567,13 +572,37 @@ await _messageProviderService.SendCustomerWelcomeMessage(_contextAccessor.WorkCo if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) redirectUrl = CommonExtensions.ModifyQueryString(redirectUrl, "returnurl", returnUrl); - return Redirect(redirectUrl); + result = Redirect(redirectUrl); + break; } default: { - return RedirectToRoute("HomePage"); + result = RedirectToRoute("HomePage"); + break; } } + + //a registration that connected the provider identity signs in once more through the provider: its session is + //still open, so it answers without a prompt and the callback starts a session-backed cookie the provider controls + if (connected && isApproved && + externalAuthenticationService.LoadAuthenticationProviderBySystemName(pending.ProviderSystemName) + is IExternalSignInPathProvider signInPathProvider) + { + //only the Standard arm approves, and it redirects to an absolute url; the provider accepts local urls only + var target = (result as RedirectResult)?.Url; + //http(s) only: on Linux a path starting with "/" also parses as an absolute file:// URI, whose + //PathAndQuery escapes the query ("?" becomes "%3F") + if (Uri.TryCreate(target, UriKind.Absolute, out var absolute) && + (absolute.Scheme == Uri.UriSchemeHttps || absolute.Scheme == Uri.UriSchemeHttp)) + target = absolute.PathAndQuery; + if (!Url.IsLocalUrl(target)) + target = null; + var path = await signInPathProvider.GetSignInPath(target); + if (!string.IsNullOrEmpty(path)) + return Redirect(HttpContext.Request.PathBase + path); + } + + return result; } //If we got this far, something failed, redisplay form diff --git a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs index cf29f3b84f..6938f116fd 100644 --- a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using Grand.Business.Core.Events.Customers; +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; @@ -25,7 +26,8 @@ public LoginValidator( ICustomerService customerService, IGroupService groupService, IEncryptionService encryptionService, ITranslationService translationService, CustomerSettings customerSettings, CaptchaSettings captchaSettings, IHttpContextAccessor contextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator, - IContextAccessor workContextAccessor, CustomerConfig customerConfig) + IContextAccessor workContextAccessor, CustomerConfig customerConfig, + IExternalSignInPolicy externalSignInPolicy) : base(validators) { var storeId = customerConfig.RegisterCustomersPerStore @@ -83,6 +85,12 @@ public LoginValidator( await contextAccessor.HttpContext!.RequestServices.GetRequiredService() .Publish(new CustomerLoginFailedEvent(customer), _); } + else if (await externalSignInPolicy.RequiredProvider(customer) is { } provider) + { + context.AddFailure(string.Format( + translationService.GetResource("Account.Login.WrongCredentials.ExternalSignInRequired"), + provider.FriendlyName)); + } break; } diff --git a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs index 55d2792940..ba6b59e9d1 100644 --- a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +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; @@ -18,7 +19,8 @@ public PasswordRecoveryConfirmValidator( ICustomerManagerService customerManagerService, ICustomerHistoryPasswordService customerHistoryPasswordService, ITranslationService translationService, CustomerSettings customerSettings, - IContextAccessor contextAccessor, CustomerConfig customerConfig) + IContextAccessor contextAccessor, CustomerConfig customerConfig, + IExternalSignInPolicy externalSignInPolicy) : base(validators) { var storeId = customerConfig.RegisterCustomersPerStore @@ -62,7 +64,9 @@ public PasswordRecoveryConfirmValidator( break; } - if (customer is not null && !customer.IsPasswordRecoveryTokenValid(x.Token)) + //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)) context.AddFailure(translationService.GetResource("Account.PasswordRecovery.WrongToken")); if (customer is not null && customer.IsPasswordRecoveryLinkExpired(customerSettings))