diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs index f200fb988..aaccb7ea1 100644 --- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs @@ -207,6 +207,19 @@ await _httpContextAccessor.HttpContext.SignOutAsync(GrandCookieAuthenticationDef } } + /// + /// Whether the current sign-in cookie references a session started at the identity provider + /// + public virtual async Task HasOidcSession() + { + if (_httpContextAccessor.HttpContext == null) + return false; + + var authenticateResult = await _httpContextAccessor.HttpContext.AuthenticateAsync( + GrandCookieAuthenticationDefaults.AuthenticationScheme); + return authenticateResult?.Succeeded == true && !string.IsNullOrEmpty(OidcSessionId(authenticateResult.Principal)); + } + /// /// Get an authenticated customer /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignOutProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignOutProvider.cs new file mode 100644 index 000000000..55960e44d --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignOutProvider.cs @@ -0,0 +1,11 @@ +namespace Grand.Business.Core.Interfaces.Authentication; + +/// An external authentication provider that can end the user's session at the provider (RP-initiated logout) +public interface IExternalSignOutProvider +{ + /// + /// Absolute URL that ends the session at the provider and returns to + /// (registered at the provider); null when the provider cannot do it now + /// + Task GetSignOutUrl(string postLogoutRedirectUri); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs index 71e0c1b4c..01cd7254e 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs @@ -28,6 +28,12 @@ public interface IGrandAuthenticationService /// Task SignOut(); + /// + /// Whether the current sign-in cookie references a session started at the identity provider; an implementation + /// without provider sessions keeps the default (no) + /// + Task HasOidcSession() => Task.FromResult(false); + /// /// Get authenticated customer /// diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs index b5e9e289f..85066d763 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs @@ -305,6 +305,22 @@ public async Task SignOut_EndsOidcSession() _oidcSessionService.Verify(x => x.End("session-1", OidcSessionEndReason.SignOut), Times.Once); } + [TestMethod] + public async Task HasOidcSession_SessionCookie_True() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + Assert.IsTrue(await _cookieAuthService.HasOidcSession()); + } + + [TestMethod] + public async Task HasOidcSession_PlainCookie_False() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(ClaimTypes.Email, "a@b.c", ClaimValueTypes.Email, "grandnode"))); + Assert.IsFalse(await _cookieAuthService.HasOidcSession()); + } + private (Customer customer, Func signedIn) PrepareSignIn(string customerId) { ClaimsPrincipal signedIn = null; diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/GrandAuthenticationServiceDefaultsTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/GrandAuthenticationServiceDefaultsTests.cs new file mode 100644 index 000000000..119b1616a --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/GrandAuthenticationServiceDefaultsTests.cs @@ -0,0 +1,33 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Domain.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class GrandAuthenticationServiceDefaultsTests +{ + [TestMethod] + public async Task HasOidcSession_ImplementationWithoutIt_ReturnsFalse() + { + IGrandAuthenticationService service = new LegacyAuthenticationService(); + + Assert.IsFalse(await service.HasOidcSession()); + } + + //an implementation written before HasOidcSession existed + private sealed class LegacyAuthenticationService : IGrandAuthenticationService + { + public Task SignIn(Customer customer, bool createPersistentCookie) => Task.CompletedTask; + + public Task SignIn(Customer customer, bool createPersistentCookie, string oidcSessionId) => Task.CompletedTask; + + public Task SignOut() => Task.CompletedTask; + + public Task GetAuthenticatedCustomer() => Task.FromResult(null); + + public Task GetCustomerGuid() => Task.FromResult(null); + + public Task SetCustomerGuid(Guid customerGuid) => Task.CompletedTask; + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs index 44d93440a..598343998 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs @@ -1,4 +1,3 @@ -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; @@ -8,6 +7,7 @@ using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Security.ExternalRegistration; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; @@ -24,10 +24,9 @@ public class BaseHomeControllerTests private class TestHomeController( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, string selectStateKey, string logoutRoute) - : BaseHomeController(countryService, translationService, authenticationService) + : BaseHomeController(countryService, translationService) { protected override string SelectStateResourceKey => selectStateKey; protected override string LogoutRouteName => logoutRoute; @@ -36,12 +35,11 @@ private class TestHomeController( private class TestHomeControllerWithSetLanguage( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, IContextAccessor contextAccessor, string selectStateKey, string logoutRoute, string areaName) - : BaseHomeControllerWithSetLanguage(countryService, translationService, authenticationService, contextAccessor) + : BaseHomeControllerWithSetLanguage(countryService, translationService, contextAccessor) { protected override string SelectStateResourceKey => selectStateKey; protected override string LogoutRouteName => logoutRoute; @@ -50,7 +48,6 @@ private class TestHomeControllerWithSetLanguage( private Mock _countryServiceMock = null!; private Mock _translationServiceMock = null!; - private Mock _authenticationServiceMock = null!; [TestInitialize] public void Setup() @@ -58,7 +55,6 @@ public void Setup() _countryServiceMock = new Mock(); _translationServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key); - _authenticationServiceMock = new Mock(); } private static void WireControllerInfrastructure(Controller controller) @@ -79,7 +75,7 @@ private static void WireControllerInfrastructure(Controller controller) private TestHomeController CreateController(string selectStateKey, string logoutRoute) { var controller = new TestHomeController(_countryServiceMock.Object, _translationServiceMock.Object, - _authenticationServiceMock.Object, selectStateKey, logoutRoute); + selectStateKey, logoutRoute); WireControllerInfrastructure(controller); return controller; } @@ -150,12 +146,26 @@ public async Task Logout_SignsOutAndRedirectsToSubclassRoute() { var controller = CreateController("Admin.Address.SelectState", "VendorLogin"); - var result = (RedirectToRouteResult)await controller.Logout(); + var providerSignOut = new Mock(); - _authenticationServiceMock.Verify(a => a.SignOut(), Times.Once); + var result = (RedirectToRouteResult)await controller.Logout(providerSignOut.Object); + + providerSignOut.Verify(a => a.SignOut(), Times.Once); Assert.AreEqual("VendorLogin", result.RouteName); } + [TestMethod] + public async Task Logout_ProviderUrl_RedirectsToProvider() + { + var controller = CreateController("Admin.Address.SelectState", "VendorLogin"); + var providerSignOut = new Mock(); + providerSignOut.Setup(a => a.SignOut()).ReturnsAsync("https://idp.example/logout"); + + var result = (RedirectResult)await controller.Logout(providerSignOut.Object); + + Assert.AreEqual("https://idp.example/logout", result.Url); + } + [TestMethod] public async Task SetLanguage_EmptyReturnUrl_RedirectsToSubclassArea() { @@ -173,7 +183,7 @@ public async Task SetLanguage_EmptyReturnUrl_RedirectsToSubclassArea() languageServiceMock.Setup(l => l.GetLanguageById("lang-1")).ReturnsAsync(new Language { Id = "lang-1" }); var controller = new TestHomeControllerWithSetLanguage(_countryServiceMock.Object, - _translationServiceMock.Object, _authenticationServiceMock.Object, contextAccessorMock.Object, + _translationServiceMock.Object, contextAccessorMock.Object, "Store.Address.SelectState", "StoreLogin", "Store"); WireControllerInfrastructure(controller); var urlHelperMock = new Mock(); @@ -207,7 +217,7 @@ public async Task SetLanguage_NonLocalReturnUrl_RedirectsToSubclassAreaIndexInst languageServiceMock.Setup(l => l.GetLanguageById(It.IsAny())).ReturnsAsync((Language)null!); var controller = new TestHomeControllerWithSetLanguage(_countryServiceMock.Object, - _translationServiceMock.Object, _authenticationServiceMock.Object, contextAccessorMock.Object, + _translationServiceMock.Object, contextAccessorMock.Object, "Store.Address.SelectState", "StoreLogin", "Store"); WireControllerInfrastructure(controller); var urlHelperMock = new Mock(); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs index c95be90e4..596c46c5c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs @@ -1,10 +1,12 @@ 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.Business.Core.Interfaces.Messages; using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Mediator; using Grand.Web.Admin.Controllers; using Grand.Web.AdminShared.Models.Common; @@ -21,12 +23,14 @@ namespace Grand.Web.Admin.Tests.Controllers; public class LoginControllerExternalSignInTests { private Mock _externalAuthenticationService = null!; + private Mock _groupService = null!; private LoginController _controller = null!; [TestInitialize] public void Setup() { _externalAuthenticationService = new Mock(); + _groupService = new Mock(); _controller = new LoginController(new CustomerSettings(), new CaptchaSettings(), new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -53,7 +57,7 @@ public async Task Index_ListsPanelProvidersOnly() _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)) .Returns([panel.Object, storefrontOnly.Object]); - var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object)).Model!; + var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object)).Model!; Assert.AreEqual(1, model.ExternalSignIn.Count); Assert.AreEqual("OpenID Connect", model.ExternalSignIn[0].Name); @@ -66,7 +70,7 @@ 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!; + var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object)).Model!; Assert.AreEqual(0, model.ExternalSignIn.Count); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs new file mode 100644 index 000000000..0316220bf --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs @@ -0,0 +1,229 @@ +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.Business.Core.Interfaces.Messages; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +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 LoginControllerProviderOnlyTests +{ + private Mock _externalAuthenticationService = null!; + private Mock _contextAccessor = null!; + private Mock _groupService = null!; + private Mock _customerManagerService = null!; + + [TestInitialize] + public void Setup() + { + _externalAuthenticationService = new Mock(); + _groupService = new Mock(); + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer()); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + _customerManagerService = new Mock(); + } + + private LoginController CreateController(string area) + { + var controller = new LoginController(new CustomerSettings(), new CaptchaSettings(), + new Mock().Object, _customerManagerService.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, _contextAccessor.Object, + new Mock().Object); + var httpContext = new DefaultHttpContext(); + httpContext.Request.PathBase = "/shop"; + controller.ControllerContext = new ControllerContext { + HttpContext = httpContext, + RouteData = new RouteData { Values = { ["area"] = area } } + }; + var url = new Mock(); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns($"/shop/{area.ToLowerInvariant()}"); + controller.Url = url.Object; + return controller; + } + + private void WithSignInPath(string? path) + { + var provider = new Mock(); + provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); + provider.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync(path); + _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)) + .Returns([provider.Object]); + } + + private static OidcLoginConfig ProviderOnly() => new() { SignInMode = OidcSignInMode.ProviderOnly }; + + [TestMethod] + public async Task Index_ProviderOnly_Store_Redirects() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fstore"); + var controller = CreateController("Store"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var redirect = result as RedirectResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fshop%2Fstore", redirect.Url); + } + + [TestMethod] + public async Task Index_ProviderOnly_Admin_Redirects() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var redirect = result as RedirectResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fshop%2Fadmin", redirect.Url); + } + + [TestMethod] + [DataRow("1")] + [DataRow("true")] + [DataRow("True")] + public async Task Index_ProviderOnly_Admin_PasswordParam_ShowsForm(string password) + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object, + password); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.PasswordFormShown); + Assert.IsFalse(model.ExternalSignInUnavailable); + Assert.AreEqual(1, model.ExternalSignIn.Count); + } + + [TestMethod] + [DataRow("0")] + [DataRow("")] + [DataRow(null)] + public async Task Index_ProviderOnly_Admin_PasswordParamNotSet_Redirects(string? password) + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object, + password); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task Index_NoSignInPath_Store_NoticeWithoutForm() + { + WithSignInPath(null); + var controller = CreateController("Store"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.ExternalSignInUnavailable); + Assert.IsFalse(model.PasswordFormShown); + } + + [TestMethod] + public async Task Index_NoSignInPath_Admin_NoticeWithForm() + { + WithSignInPath(null); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.ExternalSignInUnavailable); + Assert.IsTrue(model.PasswordFormShown); + } + + [TestMethod] + public async Task Index_Mixed_ShowsFormNoRedirect() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.PasswordFormShown); + Assert.IsFalse(model.ExternalSignInUnavailable); + } + + [TestMethod] + public async Task IndexPost_ProviderOnly_Admin_FailedAttempt_KeepsForm() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + _customerManagerService.Setup(x => x.LoginCustomer(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(CustomerLoginResults.WrongPassword); + var controller = CreateController("Admin"); + + var result = await controller.Index(new LoginModel { Email = "a@b.c", Password = "x" }, + _externalAuthenticationService.Object, ProviderOnly()); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.PasswordFormShown); + } + + [TestMethod] + public async Task IndexPost_ProviderOnly_Vendor_NoPasswordForm() + { + WithSignInPath(null); + var controller = CreateController("Vendor"); + + var result = await controller.Index(new LoginModel { Email = "a@b.c", Password = "x" }, + _externalAuthenticationService.Object, ProviderOnly()); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsFalse(model.PasswordFormShown); + Assert.IsTrue(model.ExternalSignInUnavailable); + } + + [TestMethod] + [DataRow("Store")] + [DataRow("Admin")] + public async Task Index_ProviderOnly_SignedInWithoutAccess_NoRedirect(string area) + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fpanel"); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + var controller = CreateController(area); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.SignedInWithoutAccess); + Assert.AreEqual(area == "Admin", model.PasswordFormShown); + } + + [TestMethod] + public async Task Index_Mixed_SignedIn_ShowsForm() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsFalse(model.SignedInWithoutAccess); + Assert.IsTrue(model.PasswordFormShown); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ProviderSignOutTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ProviderSignOutTests.cs new file mode 100644 index 000000000..79a1081c2 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ProviderSignOutTests.cs @@ -0,0 +1,169 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.ExternalRegistration; + +[TestClass] +public class ProviderSignOutTests +{ + private const string ProviderUrl = "https://idp.example/logout?x=1"; + + private Mock _auth; + private Mock _provider; + private Mock _contextAccessor; + private OidcLoginConfig _config; + private List _providers; + private DefaultHttpContext _httpContext; + + [TestInitialize] + public void Init() + { + _auth = new Mock(); + _provider = new Mock(); + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync(ProviderUrl); + _providers = [_provider.Object]; + _config = new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }; + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(new Store { Url = "https://shop.example" }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + _auth.Setup(x => x.HasOidcSession()).ReturnsAsync(true); + _httpContext = new DefaultHttpContext(); + } + + private ProviderSignOut Service() => + new(_auth.Object, _providers, _config, _contextAccessor.Object, + new HttpContextAccessor { HttpContext = _httpContext }, NullLogger.Instance); + + [TestMethod] + [DataRow("same-origin")] + [DataRow("none")] + public async Task SameOriginOrTypedNavigation_ReturnsProviderUrl(string site) + { + _httpContext.Request.Headers["Sec-Fetch-Site"] = site; + + Assert.AreEqual(ProviderUrl, await Service().SignOut()); + } + + [TestMethod] + [DataRow("cross-site")] + [DataRow("same-site")] + public async Task CrossSiteNavigation_SignsOutLocallyOnly(string site) + { + _httpContext.Request.Headers["Sec-Fetch-Site"] = site; + + Assert.IsNull(await Service().SignOut()); + + _provider.Verify(x => x.GetSignOutUrl(It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task ProviderOnly_SessionCookie_ReturnsProviderUrl() + { + var url = await Service().SignOut(); + + Assert.AreEqual(ProviderUrl, url); + _provider.Verify(x => x.GetSignOutUrl("https://shop.example/"), Times.Once); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task StoreUrlWithTrailingSlash_TargetEndsWithOneSlash() + { + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(new Store { Url = "https://shop.example/" }); + _contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + + await Service().SignOut(); + + _provider.Verify(x => x.GetSignOutUrl("https://shop.example/"), Times.Once); + } + + [TestMethod] + public async Task UrlIsFetchedBeforeLocalSignOut() + { + var order = new List(); + _auth.Setup(x => x.HasOidcSession()).ReturnsAsync(true).Callback(() => order.Add("check")); + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync(ProviderUrl).Callback(() => order.Add("url")); + _auth.Setup(x => x.SignOut()).Returns(Task.CompletedTask).Callback(() => order.Add("signout")); + + await Service().SignOut(); + + CollectionAssert.AreEqual(new[] { "check", "url", "signout" }, order); + } + + [TestMethod] + public async Task Mixed_ReturnsNull_StillSignsOut() + { + _config.SignInMode = OidcSignInMode.Mixed; + + Assert.IsNull(await Service().SignOut()); + + _provider.Verify(x => x.GetSignOutUrl(It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task PlainCookie_ReturnsNull() + { + _auth.Setup(x => x.HasOidcSession()).ReturnsAsync(false); + + Assert.IsNull(await Service().SignOut()); + + _provider.Verify(x => x.GetSignOutUrl(It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task NoProvider_ReturnsNull() + { + _providers.Clear(); + + Assert.IsNull(await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task ProviderThrows_ReturnsNull_StillSignsOut() + { + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ThrowsAsync(new InvalidOperationException("discovery down")); + + Assert.IsNull(await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + [DataRow("javascript:alert(1)")] + [DataRow("/relative/logout")] + [DataRow("ftp://idp.example/logout")] + public async Task UrlNotAbsoluteHttp_ReturnsNull_StillSignsOut(string url) + { + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync(url); + + Assert.IsNull(await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task FirstProviderWithUrlWins() + { + var empty = new Mock(); + empty.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync((string)null); + _providers.Insert(0, empty.Object); + + Assert.AreEqual(ProviderUrl, await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs new file mode 100644 index 000000000..3fa9f7e34 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs @@ -0,0 +1,50 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Marketing.Newsletters; +using Grand.Domain.Customers; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Web.Commands.Handler.Customers; +using Grand.Web.Commands.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Commands.Customers; + +[TestClass] +public class UpdateCustomerInfoCommandHandlerTests +{ + private Mock _customerService; + + private async Task Update(OidcLoginConfig config) + { + _customerService = new Mock(); + var handler = new UpdateCustomerInfoCommandHandler(new Mock().Object, + new Mock().Object, new Mock().Object, + _customerService.Object, new CustomerSettings { AllowUsersToChangeEmail = true }, new TaxSettings(), config); + var customer = new Customer { Id = "c1", Email = "old@x.com" }; + await handler.Handle(new UpdateCustomerInfoCommand { + Customer = customer, + Store = new Domain.Stores.Store { Id = "s1" }, + Model = new CustomerInfoModel { Email = "new@x.com", FirstName = "A", LastName = "B" }, + CustomerAttributes = [] + }, CancellationToken.None); + return customer; + } + + [TestMethod] + public async Task UpdateInfo_ProviderOnly_EmailNotChanged() + { + var customer = await Update(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }); + Assert.AreEqual("old@x.com", customer.Email); + } + + [TestMethod] + public async Task UpdateInfo_Mixed_EmailChanged() + { + var customer = await Update(new OidcLoginConfig()); + Assert.AreEqual("new@x.com", customer.Email); + } +} diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs index 41afa9a64..84cd7f97d 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs @@ -79,7 +79,7 @@ public void Init() public async Task Get_Pending_PrefillsAndMarksExternal() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); - var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, new OidcLoginConfig())).Result; var model = (RegisterModel)view.Model; Assert.AreEqual("john@example.com", model.Email); Assert.AreEqual("John", model.FirstName); @@ -94,7 +94,7 @@ public async Task Post_Pending_UsesPendingEmail_RandomPassword_Links() RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); await _controller.Register(new RegisterModel { Email = "evil@b.c", ExternalRegistration = false }, null, - _external.Object, _externalAuth.Object); + _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("john@example.com", request.Email); Assert.IsFalse(string.IsNullOrEmpty(request.Password)); _external.Verify(x => x.Complete(_guest, "s1"), Times.Once); @@ -111,7 +111,7 @@ public async Task Post_Pending_ApprovalFollowsPolicy(UserRegistrationType type, _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = verified }); RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); - await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual(approved, request.IsApproved); } @@ -120,7 +120,7 @@ public async Task Post_Pending_Unverified_SendsActivation() { _settings.UserRegistrationType = UserRegistrationType.Standard; _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); - var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); _messages.Verify(x => x.SendCustomerEmailValidationMessage(_guest, It.IsAny(), "l1"), Times.Once); Assert.AreEqual((int)UserRegistrationType.EmailValidation, ((RedirectToRouteResult)result).RouteValues["resultId"]); } @@ -132,7 +132,7 @@ public async Task Post_Pending_Unverified_TypedPassword_NotConnected() _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); - await _controller.Register(new RegisterModel { Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel { Password = "Secret1!" }, null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("john@example.com", request.Email); Assert.AreEqual("Secret1!", request.Password); _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); @@ -143,7 +143,7 @@ public async Task Post_Pending_Unverified_TypedPassword_NotConnected() public async Task Get_Pending_Unverified_AsksForPassword() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); - var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, new OidcLoginConfig())).Result; var model = (RegisterModel)view.Model; Assert.IsTrue(model.ExternalRegistration); Assert.IsFalse(model.ExternalConnects); @@ -154,7 +154,7 @@ public async Task Post_Pending_LinkFails_WarnsCustomer() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); - await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.IsNotNull(_controller.TempData["grand.notifications.Warning"]); } @@ -164,7 +164,7 @@ public async Task Post_NoPending_RegularPath_NoLink() _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); - await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("Secret1!", request.Password); _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); } @@ -185,7 +185,7 @@ public async Task Post_Pending_LinkedAndSignedIn_GoesThroughProviderSignIn() _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); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), "/cart", _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("/oidc/login?returnUrl=%2Fregisterresult%2F1", result.Url); provider.Verify(x => x.GetSignInPath(It.Is(u => @@ -199,7 +199,7 @@ public async Task Post_Pending_NotApproved_NoProviderRedirect() _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); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.IsInstanceOfType(result, typeof(RedirectToRouteResult)); provider.Verify(x => x.GetSignInPath(It.IsAny()), Times.Never); } @@ -211,7 +211,7 @@ public async Task Post_Pending_NotLinked_NoProviderRedirect() _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); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.IsFalse(result.Url.Contains("/oidc/login")); provider.Verify(x => x.GetSignInPath(It.IsAny()), Times.Never); } @@ -224,7 +224,7 @@ public async Task Post_Pending_ProviderWithoutSignInPath_NoProviderRedirect() _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); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("https://localhost/registerresult/1", result.Url); } @@ -235,7 +235,7 @@ public async Task Post_Pending_NullSignInPath_NoProviderRedirect() _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); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("https://localhost/registerresult/1", result.Url); } } diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs new file mode 100644 index 000000000..70aac4069 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs @@ -0,0 +1,410 @@ +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.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerProviderOnlyTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new("ExternalAuth.OpenIdConnect", "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _provider; + private Mock _externalAuth; + private Mock _external; + private Mock _customerManager; + private Mock _customers; + private OidcLoginConfig _providerOnly; + private Mock _groups; + private Customer _currentCustomer; + private CustomerSettings _customerSettings; + private AccountController _controller; + + [TestInitialize] + public void Init() + { + _provider = new Mock(); + _provider.Setup(p => p.SystemName).Returns("ExternalAuth.OpenIdConnect"); + _externalAuth = new Mock(); + _externalAuth.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) + .Returns(new List { _provider.Object }); + _external = new Mock(); + _providerOnly = new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }; + + var work = new Mock(); + _currentCustomer = new Customer { Id = "g1" }; + work.Setup(w => w.CurrentCustomer).Returns(_currentCustomer); + work.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "l1" }); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RegisterModel()); + _groups = new Mock(); + _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + + _customerManager = new Mock(); + _customers = new Mock(); + _controller = new AccountController(new Mock().Object, + new Mock().Object, context.Object, _customers.Object, + _groups.Object, _customerManager.Object, new Mock().Object, + mediator.Object, new Mock().Object, new CaptchaSettings(), + _customerSettings = new CustomerSettings { + UserRegistrationType = UserRegistrationType.Standard, TwoFactorAuthenticationEnabled = true, + AllowUsersToDeleteAccount = true + }, new CustomerConfig()); + _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("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; + } + + [TestMethod] + public async Task Login_ProviderOnly_RedirectsToProvider() + { + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Login_ProviderOnly_PathBaseIsPrepended() + { + _controller.HttpContext.Request.PathBase = "/shop"; + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Login_ExternalReturnUrl_NotForwarded() + { + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync("/oidc/login"); + var result = await _controller.Login(null, "https://evil.example", _providerOnly, _externalAuth.Object); + Assert.AreEqual("/oidc/login", ((RedirectResult)result).Url); + _provider.Verify(p => p.GetSignInPath(null), Times.Once); + _provider.Verify(p => p.GetSignInPath("https://evil.example"), Times.Never); + } + + [TestMethod] + public async Task Login_NoSignInPath_ShowsNotice() + { + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync((string)null); + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsTrue(model.ExternalSignInUnavailable); + } + + [TestMethod] + public async Task Login_ProviderOnly_SignedIn_ShowsSignOutNotice_ProviderNotAsked() + { + _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync("/oidc/login"); + + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsTrue(model.SignedInWithoutAccess); + _provider.Verify(p => p.GetSignInPath(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Login_Mixed_SignedIn_ShowsForm() + { + _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); + + var result = await _controller.Login(null, "/cart", new OidcLoginConfig(), _externalAuth.Object); + + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsFalse(model.SignedInWithoutAccess); + Assert.IsFalse(model.ExternalSignInUnavailable); + } + + [TestMethod] + public async Task Login_ProviderOnly_FirstProviderWithoutPath_UsesNextProvider() + { + var first = new Mock(); + first.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync((string)null); + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + _externalAuth.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) + .Returns(new List { first.Object, _provider.Object }); + + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Login_Mixed_ShowsForm() + { + var result = await _controller.Login(null, "/cart", new OidcLoginConfig(), _externalAuth.Object); + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsFalse(model.ExternalSignInUnavailable); + _provider.Verify(p => p.GetSignInPath(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Register_ProviderOnly_NoPending_RedirectsToProvider() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly, "/cart")).Result; + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Register_AutoProvision_RegistrationDisabled_RedirectsToProvider() + { + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + _providerOnly.AutoProvision = true; + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly, "/cart")).Result; + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Register_AutoProvision_Pending_RedirectsToProvider_NoForm() + { + _providerOnly.AutoProvision = true; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync("/oidc/login"); + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + Assert.AreEqual("/oidc/login", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Register_ProviderOnly_RegistrationDisabled_WithoutAutoProvision_RegisterResult() + { + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + Assert.AreEqual("RegisterResult", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task Register_ProviderOnly_NoPending_NoSignInPath_ShowsNotice() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync((string)null); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + Assert.AreEqual("Login", view.ViewName); + Assert.IsTrue(((LoginModel)view.Model).ExternalSignInUnavailable); + } + + [TestMethod] + public async Task Register_ProviderOnly_Pending_ShowsForm() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + var model = (RegisterModel)view.Model; + Assert.IsTrue(model.ExternalRegistration); + Assert.AreEqual("john@example.com", model.Email); + _provider.Verify(p => p.GetSignInPath(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RegisterPost_ProviderOnly_UnverifiedPending_RedirectsToLogin() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task RegisterPost_ProviderOnly_NoPending_RedirectsToLogin() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task RegisterExternalCancel_ProviderOnly_ClearsPendingAndGoesHome() + { + var result = await _controller.RegisterExternalCancel(_external.Object, _providerOnly); + + Assert.AreEqual("HomePage", ((RedirectToRouteResult)result).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task RegisterExternalCancel_Mixed_BackToRegister() + { + var result = await _controller.RegisterExternalCancel(_external.Object, new OidcLoginConfig()); + + Assert.AreEqual("Register", ((RedirectToRouteResult)result).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public void PasswordRecovery_ProviderOnly_RedirectsToLogin() + { + var result = _controller.PasswordRecovery(_providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task PasswordRecoveryPost_ProviderOnly_RedirectsToLogin() + { + var result = await _controller.PasswordRecovery(new PasswordRecoveryModel { Email = "a@b.c" }, _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result.Result).RouteName); + } + + [TestMethod] + public async Task PasswordRecoveryConfirm_ProviderOnly_RedirectsToLogin() + { + var get = await _controller.PasswordRecoveryConfirm("t", "a@b.c", _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)get.Result).RouteName); + var post = await _controller.PasswordRecoveryConfirm(new PasswordRecoveryConfirmModel(), _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)post).RouteName); + } + + [TestMethod] + public async Task ChangePassword_ProviderOnly_Redirects() + { + var get = await _controller.ChangePassword(_providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)get).RouteName); + var post = await _controller.ChangePassword( + new ChangePasswordModel { OldPassword = "a", NewPassword = "b", ConfirmNewPassword = "b" }, _providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName); + _customerManager.Verify(m => m.ChangePassword(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ChangePassword_Mixed_ChangesPassword() + { + _customers.Setup(c => c.GetCustomerById(It.IsAny())).ReturnsAsync(new Customer { Id = "g1" }); + var post = await _controller.ChangePassword( + new ChangePasswordModel { OldPassword = "a", NewPassword = "b", ConfirmNewPassword = "b" }, + new OidcLoginConfig()); + Assert.IsInstanceOfType(post); + _customerManager.Verify(m => m.ChangePassword(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task EnableTwoFactor_ProviderOnly_Redirects() + { + var model = new CustomerInfoModel.TwoFactorAuthenticationModel { Code = "123456", SecretKey = "k" }; + var get = await _controller.EnableTwoFactorAuthenticator(_providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)get).RouteName); + var post = await _controller.EnableTwoFactorAuthenticator(model, + new Mock().Object, _providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName); + _customers.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _customers.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EnableTwoFactor_Mixed_ShowsForm() + { + var get = await _controller.EnableTwoFactorAuthenticator(new OidcLoginConfig()); + Assert.IsInstanceOfType(get); + } + + private void TwoFactorEnabled() => + _currentCustomer.UserFields.Add(new UserField + { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + + [TestMethod] + public async Task DisableTwoFactor_ProviderOnly_Redirects() + { + //two-factor is on: only the provider-only guard can send the customer away + TwoFactorEnabled(); + var get = await _controller.DisableTwoFactorAuthenticator(_providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)get).RouteName); + var post = await _controller.DisableTwoFactorAuthenticator( + new CustomerInfoModel.TwoFactorAuthorizationModel { Code = "123456" }, + new Mock().Object, _providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName); + _customers.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task DisableTwoFactor_Mixed_ShowsForm() + { + TwoFactorEnabled(); + var get = await _controller.DisableTwoFactorAuthenticator(new OidcLoginConfig()); + Assert.IsInstanceOfType(get); + } + + private void ExistingLinks(params string[] providers) + { + _externalAuth.Setup(x => x.GetExternalIdentifiers(It.IsAny())).ReturnsAsync( + (IList)providers + .Select(p => new ExternalAuthentication { Id = p, ProviderSystemName = p }).ToList()); + } + + [TestMethod] + public async Task RemoveExternalAssociation_ProviderOnly_OidcKept() + { + ExistingLinks("ExternalAuth.OpenIdConnect", "ExternalAuth.Google"); + await _controller.RemoveExternalAssociation("ExternalAuth.OpenIdConnect", _externalAuth.Object, _providerOnly); + _externalAuth.Verify(x => x.DeleteExternalAuthentication(It.IsAny()), Times.Never); + + await _controller.RemoveExternalAssociation("ExternalAuth.Google", _externalAuth.Object, _providerOnly); + _externalAuth.Verify(x => x.DeleteExternalAuthentication( + It.Is(e => e.Id == "ExternalAuth.Google")), Times.Once); + } + + [TestMethod] + public async Task RemoveExternalAssociation_Mixed_OidcRemoved() + { + ExistingLinks("ExternalAuth.OpenIdConnect"); + await _controller.RemoveExternalAssociation("ExternalAuth.OpenIdConnect", _externalAuth.Object, + new OidcLoginConfig()); + _externalAuth.Verify(x => x.DeleteExternalAuthentication( + It.Is(e => e.Id == "ExternalAuth.OpenIdConnect")), Times.Once); + } + + [TestMethod] + public async Task DeleteAccount_ProviderOnly_AsksNoPassword_AlsoOnRedisplay() + { + var get = (DeleteAccountModel)((ViewResult)_controller.DeleteAccount(_providerOnly)).Model; + Assert.IsFalse(get.PasswordRequired); + + _controller.ModelState.AddModelError("", "failed"); + var post = (DeleteAccountModel)((ViewResult)await _controller.DeleteAccount(new DeleteAccountModel(), _providerOnly)).Model; + Assert.IsFalse(post.PasswordRequired); + } + + [TestMethod] + public void DeleteAccount_Mixed_AsksPassword() + { + var get = (DeleteAccountModel)((ViewResult)_controller.DeleteAccount(new OidcLoginConfig())).Model; + Assert.IsTrue(get.PasswordRequired); + } + + [TestMethod] + public void PasswordRecovery_Mixed_ShowsForm() + { + var result = _controller.PasswordRecovery(new OidcLoginConfig()); + Assert.IsInstanceOfType(result); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs new file mode 100644 index 000000000..18910ba29 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs @@ -0,0 +1,90 @@ +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.Marketing.Newsletters; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Messages; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Handlers.Customers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.Handlers.Customers; + +[TestClass] +public class GetInfoHandlerTests +{ + private const string Oidc = "ExternalAuth.OpenIdConnect"; + private const string Google = "ExternalAuth.Google"; + + private static async Task Handle(OidcLoginConfig config) + { + var external = new Mock(); + external.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) + .Returns(new List()); + external.Setup(x => x.GetExternalIdentifiers(It.IsAny())).ReturnsAsync( + (IList)new List { + new() { Id = "1", ProviderSystemName = Oidc }, + new() { Id = "2", ProviderSystemName = Google } + }); + external.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(true); + var providerMock = new Mock(); + providerMock.Setup(p => p.FriendlyName).Returns("Provider"); + external.Setup(x => x.LoadAuthenticationProviderBySystemName(It.IsAny())).Returns(providerMock.Object); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var newsletterCategories = new Mock(); + newsletterCategories.Setup(s => s.GetAllNewsletterCategory()).ReturnsAsync(new List()); + + var handler = new GetInfoHandler(new Mock().Object, + newsletterCategories.Object, new Mock().Object, new Mock().Object, + external.Object, mediator.Object, + new CustomerSettings { AllowUsersToChangeEmail = true, TwoFactorAuthenticationEnabled = true }, + new TaxSettings(), new Mock().Object, config); + + return await handler.Handle(new GetInfo { + Customer = new Customer { Id = "c1", Email = "a@b.c" }, + Store = new Domain.Stores.Store { Id = "s1" }, + Language = new Language { Id = "l1" } + }, CancellationToken.None); + } + + [TestMethod] + public async Task Info_ProviderOnly_EmailReadOnlyTwoFactorHidden() + { + var model = await Handle(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }); + Assert.IsFalse(model.AllowUsersToChangeEmail); + Assert.IsFalse(model.TwoFactorAvailable); + } + + [TestMethod] + public async Task Info_Mixed_EmailEditableTwoFactorShown() + { + var model = await Handle(new OidcLoginConfig()); + Assert.IsTrue(model.AllowUsersToChangeEmail); + Assert.IsTrue(model.TwoFactorAvailable); + } + + [TestMethod] + public async Task Info_ProviderOnly_OidcRecordNotRemovable_GoogleRemovable() + { + var model = await Handle(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }); + Assert.IsFalse(model.AssociatedExternalAuthRecords.Single(x => x.Id == "1").CanRemove); + Assert.IsTrue(model.AssociatedExternalAuthRecords.Single(x => x.Id == "2").CanRemove); + } + + [TestMethod] + public async Task Info_Mixed_AllRecordsRemovable() + { + var model = await Handle(new OidcLoginConfig()); + Assert.IsTrue(model.AssociatedExternalAuthRecords.All(x => x.CanRemove)); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs new file mode 100644 index 000000000..564268b95 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs @@ -0,0 +1,33 @@ +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Vendors; +using Grand.Infrastructure.Configuration; +using Grand.Web.Features.Handlers.Customers; +using Grand.Web.Features.Models.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Tests.Features.Handlers.Customers; + +[TestClass] +public class GetNavigationHandlerTests +{ + private static async Task HideChangePassword(OidcLoginConfig config) + { + var handler = new GetNavigationHandler(new CustomerSettings(), new LoyaltyPointsSettings(), + new OrderSettings(), new VendorSettings(), config); + var model = await handler.Handle(new GetNavigation { Customer = new Customer() }, CancellationToken.None); + return model.HideChangePassword; + } + + [TestMethod] + public async Task Navigation_ProviderOnly_HidesChangePassword() + { + Assert.IsTrue(await HideChangePassword(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly })); + } + + [TestMethod] + public async Task Navigation_Mixed_ShowsChangePassword() + { + Assert.IsFalse(await HideChangePassword(new OidcLoginConfig())); + } +} diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/DeleteAccountValidatorTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/DeleteAccountValidatorTests.cs new file mode 100644 index 000000000..2c01e0f24 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/DeleteAccountValidatorTests.cs @@ -0,0 +1,55 @@ +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +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 DeleteAccountValidatorTests +{ + private static DeleteAccountValidator Validator(OidcLoginConfig config, bool passwordMatches) + { + var encryption = new Mock(); + encryption.Setup(e => e.VerifyPassword(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(passwordMatches); + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer { Password = "pw" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + return new DeleteAccountValidator(new List>(), encryption.Object, + new CustomerSettings(), context.Object, translation.Object, config); + } + + [TestMethod] + public async Task DeleteAccountValidator_ProviderOnly_NoPasswordNeeded() + { + var result = await Validator(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }, false) + .ValidateAsync(new DeleteAccountModel()); + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task DeleteAccountValidator_Mixed_PasswordRequired() + { + var result = await Validator(new OidcLoginConfig(), true).ValidateAsync(new DeleteAccountModel()); + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public async Task DeleteAccountValidator_Mixed_WrongPasswordRejected() + { + var result = await Validator(new OidcLoginConfig(), false) + .ValidateAsync(new DeleteAccountModel { Password = "x" }); + Assert.IsFalse(result.IsValid); + } +} 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 1b8dab7d3..d4b214977 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 @@ -17,52 +17,66 @@ @Loc["Admin.Login.Title"] - - @if (Model.UsernamesEnabled) - { - - @Loc["Admin.Login.UserName"] - - + @if (Model.PasswordFormShown) + { + + @if (Model.UsernamesEnabled) + { + + @Loc["Admin.Login.UserName"] + + + + - - - } - else - { + } + else + { + + @Loc["Admin.Login.Email"] + + + + + + } - @Loc["Admin.Login.Email"] + @Loc["Admin.Login.Password"] - + - + - } - - @Loc["Admin.Login.Password"] - - - - - - - - - - + + + + + + + @Loc["Admin.Login.RememberMe"] - @Loc["Admin.Login.RememberMe"] + @Loc["Admin.Login.ForgotPassword"] - @Loc["Admin.Login.ForgotPassword"] + @if (Model.DisplayCaptcha) + { + + + + } - @if (Model.DisplayCaptcha) - { - - - - } - - + + } + @if (Model.SignedInWithoutAccess) + { + @Loc["Admin.Login.SignedInWithoutAccess"] + + @Loc["Admin.Header.Logout"] + + } + @if (Model.ExternalSignInUnavailable) + { + @Loc["Account.Login.ExternalSignInUnavailable"] + } @foreach (var signIn in Model.ExternalSignIn) { diff --git a/src/Web/Grand.Web.Admin/Controllers/HomeController.cs b/src/Web/Grand.Web.Admin/Controllers/HomeController.cs index 878e00a12..b119c9919 100644 --- a/src/Web/Grand.Web.Admin/Controllers/HomeController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/HomeController.cs @@ -29,7 +29,6 @@ public class HomeController : BaseHomeControllerWithSetLanguage public HomeController( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, IContextAccessor contextAccessor, IStoreService storeService, ICustomerService customerService, @@ -38,7 +37,7 @@ public HomeController( IProductsReportService productsReportService, ILogger logger, IMediator mediator) - : base(countryService, translationService, authenticationService, contextAccessor) + : base(countryService, translationService, contextAccessor) { _contextAccessor = contextAccessor; _storeService = storeService; diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs index ffb04bfe2..778a75232 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs @@ -1,15 +1,14 @@ -using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Web.Common.Controllers; +using Grand.Web.Common.Security.ExternalRegistration; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.AdminShared.Controllers; public abstract class BaseHomeController( ICountryService countryService, - ITranslationService translationService, - IGrandAuthenticationService authenticationService) + ITranslationService translationService) : BaseController { /// @@ -61,9 +60,9 @@ public async Task GetStatesByCountryId(string countryId, bool? ad return Json(result); } - public async Task Logout() + public async Task Logout([FromServices] IProviderSignOut providerSignOut) { - await authenticationService.SignOut(); - return RedirectToRoute(LogoutRouteName); + var providerUrl = await providerSignOut.SignOut(); + return providerUrl != null ? Redirect(providerUrl) : RedirectToRoute(LogoutRouteName); } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeControllerWithSetLanguage.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeControllerWithSetLanguage.cs index 27e9db466..a6c7e84a8 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeControllerWithSetLanguage.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeControllerWithSetLanguage.cs @@ -1,4 +1,3 @@ -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; @@ -11,9 +10,8 @@ namespace Grand.Web.AdminShared.Controllers; public abstract class BaseHomeControllerWithSetLanguage( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, IContextAccessor contextAccessor) - : BaseHomeController(countryService, translationService, authenticationService) + : BaseHomeController(countryService, translationService) { /// The area name this host's own Home/Index route resolves under. protected abstract string AreaName { get; } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs index 83d53e08a..aab0f6e03 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs @@ -1,11 +1,13 @@ 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.Customers; using Grand.Business.Core.Interfaces.Messages; using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Common; @@ -54,20 +56,46 @@ protected virtual string GetCurrentArea() return RouteData.Values["area"]?.ToString() ?? "Admin"; } - public async Task Index([FromServices] IExternalAuthenticationService externalAuthenticationService) + public async Task Index([FromServices] IExternalAuthenticationService externalAuthenticationService, + [FromServices] OidcLoginConfig oidcLoginConfig, [FromServices] IGroupService groupService, + string password = null) { var model = new LoginModel { UsernamesEnabled = _customerSettings.UsernamesEnabled, DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage }; await PrepareExternalSignIn(model, externalAuthenticationService); + if (!oidcLoginConfig.ProviderOnly) + { + model.PasswordFormShown = true; + return View(model); + } + + //the identity provider is the only sign-in: go there, or say it cannot be reached (never redirect back here); + //only the Admin panel keeps the password form, for break-glass accounts, behind ?password=1 + var keepsPasswordForm = KeepsPasswordForm(); + //a signed-in account without access to the panel is sent here: the provider would sign it in again and return + //to the panel (a loop), so offer the sign-out instead + model.SignedInWithoutAccess = await groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer); + if (model.ExternalSignIn.Count > 0 && !model.SignedInWithoutAccess && + !(keepsPasswordForm && PasswordRequested(password))) + return Redirect(model.ExternalSignIn[0].Url); + model.PasswordFormShown = keepsPasswordForm; + model.ExternalSignInUnavailable = model.ExternalSignIn.Count == 0; return View(model); } + //?password=1 (as documented) or ?password=true; a bool parameter would not bind "1" + private static bool PasswordRequested(string password) => + password == "1" || string.Equals(password, "true", StringComparison.OrdinalIgnoreCase); + + private bool KeepsPasswordForm() => PanelSignInSurface.FromArea(GetCurrentArea()) == SignInSurface.AdminPanel; + [HttpPost] [AutoValidateAntiforgeryToken] public virtual async Task Index(LoginModel model, - [FromServices] IExternalAuthenticationService externalAuthenticationService) + [FromServices] IExternalAuthenticationService externalAuthenticationService, + [FromServices] OidcLoginConfig oidcLoginConfig) { if (ModelState.IsValid) { @@ -98,6 +126,9 @@ await _customerManagerService.LoginCustomer( model.UsernamesEnabled = _customerSettings.UsernamesEnabled; model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage; await PrepareExternalSignIn(model, externalAuthenticationService); + //a failed break-glass attempt stays on the form; the other panels never show it in ProviderOnly + model.PasswordFormShown = !oidcLoginConfig.ProviderOnly || KeepsPasswordForm(); + model.ExternalSignInUnavailable = oidcLoginConfig.ProviderOnly && model.ExternalSignIn.Count == 0; return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs b/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs index 42094a7c2..c5c28c51a 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Common/LoginModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; using System.ComponentModel.DataAnnotations; namespace Grand.Web.AdminShared.Models.Common; @@ -25,6 +26,18 @@ public class LoginModel : BaseModel public bool DisplayCaptcha { get; set; } public ICaptchaValidModel Captcha { get; set; } = new CaptchaModel(); + /// Set by the server only: the password fields are shown (not in ProviderOnly, except for the Admin break-glass) + [BindNever] + public bool PasswordFormShown { get; set; } + + /// Set by the server only: ProviderOnly and no provider can sign in + [BindNever] + public bool ExternalSignInUnavailable { get; set; } + + /// Set by the server only: a registered account is signed in but has no access to this panel + [BindNever] + public bool SignedInWithoutAccess { get; set; } + public IList ExternalSignIn { get; set; } = new List(); public class ExternalSignInButton diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ProviderSignOut.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ProviderSignOut.cs new file mode 100644 index 000000000..609da5cc8 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ProviderSignOut.cs @@ -0,0 +1,74 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +/// Signs the customer out locally and finds where the identity provider ends its own session +public interface IProviderSignOut +{ + /// + /// Signs out locally; returns the URL to redirect to so the session at the identity provider ends too, or null + /// when the sign-in did not come from a provider (or the provider cannot end it) + /// + Task SignOut(); +} + +public class ProviderSignOut( + IGrandAuthenticationService authenticationService, + IEnumerable signOutProviders, + OidcLoginConfig oidcLoginConfig, + IContextAccessor contextAccessor, + IHttpContextAccessor httpContextAccessor, + ILogger logger) : IProviderSignOut +{ + public async Task SignOut() + { + string providerUrl = null; + try + { + //the cookie is what tells whether the sign-in came from the provider: look before it is removed + if (oidcLoginConfig.ProviderOnly && !IsCrossSite() && await authenticationService.HasOidcSession()) + providerUrl = await GetProviderUrl(); + } + catch (Exception ex) + { + //never keep the customer signed in because the provider could not be asked + logger.LogWarning(ex, "Could not get the sign-out URL of the identity provider"); + } + + await authenticationService.SignOut(); + return providerUrl; + } + + //the logout URL takes a plain GET: a link on another site may end the session here, never the single sign-on + //session at the provider (every application of the user). Browsers without Fetch Metadata send no header + private bool IsCrossSite() + { + var site = httpContextAccessor.HttpContext?.Request.Headers["Sec-Fetch-Site"].ToString(); + return !string.IsNullOrEmpty(site) && site != "same-origin" && site != "none"; + } + + private async Task GetProviderUrl() + { + //the canonical store address, never the Host header + var postLogoutRedirectUri = contextAccessor.StoreContext.CurrentStore.Url.TrimEnd('/') + "/"; + foreach (var provider in signOutProviders) + { + var url = await provider.GetSignOutUrl(postLogoutRedirectUri); + if (string.IsNullOrEmpty(url)) + continue; + + //the URL ends up in a redirect: only an absolute http(s) address is accepted + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + return url; + + logger.LogWarning("An identity provider returned a sign-out URL that is not an absolute http(s) address"); + } + + return null; + } +} diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index 52c787b62..491b18718 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -24,6 +24,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/Web/Grand.Web.Store/Areas/Store/Views/Login/Index.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Login/Index.cshtml index dbd3ad3d6..bd77e1ac7 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 @@ -17,52 +17,66 @@ @Loc["Admin.Login.Title"] - - @if (Model.UsernamesEnabled) - { - - @Loc["Admin.Login.UserName"] - - + @if (Model.PasswordFormShown) + { + + @if (Model.UsernamesEnabled) + { + + @Loc["Admin.Login.UserName"] + + + + - - - } - else - { + } + else + { + + @Loc["Admin.Login.Email"] + + + + + + } - @Loc["Admin.Login.Email"] + @Loc["Admin.Login.Password"] - + - + - } - - @Loc["Admin.Login.Password"] - - - - - - - - - - + + + + + + + @Loc["Admin.Login.RememberMe"] - @Loc["Admin.Login.RememberMe"] + @Loc["Admin.Login.ForgotPassword"] - @Loc["Admin.Login.ForgotPassword"] + @if (Model.DisplayCaptcha) + { + + + + } - @if (Model.DisplayCaptcha) - { - - - - } - - + + } + @if (Model.SignedInWithoutAccess) + { + @Loc["Admin.Login.SignedInWithoutAccess"] + + @Loc["Admin.Header.Logout"] + + } + @if (Model.ExternalSignInUnavailable) + { + @Loc["Account.Login.ExternalSignInUnavailable"] + } @foreach (var signIn in Model.ExternalSignIn) { diff --git a/src/Web/Grand.Web.Store/Controllers/HomeController.cs b/src/Web/Grand.Web.Store/Controllers/HomeController.cs index e2e5360c6..68ad6410e 100644 --- a/src/Web/Grand.Web.Store/Controllers/HomeController.cs +++ b/src/Web/Grand.Web.Store/Controllers/HomeController.cs @@ -20,10 +20,9 @@ public class HomeController : BaseHomeControllerWithSetLanguage public HomeController( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, IContextAccessor contextAccessor, ILogger logger) - : base(countryService, translationService, authenticationService, contextAccessor) + : base(countryService, translationService, contextAccessor) { _logger = logger; } 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 5e8c03fea..836c7152c 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 @@ -17,52 +17,66 @@ @Loc["Vendor.Login.Title"] - - @if (Model.UsernamesEnabled) - { - - @Loc["Vendor.Login.UserName"] - - + @if (Model.PasswordFormShown) + { + + @if (Model.UsernamesEnabled) + { + + @Loc["Vendor.Login.UserName"] + + + + - - - } - else - { + } + else + { + + @Loc["Vendor.Login.Email"] + + + + + + } - @Loc["Vendor.Login.Email"] + @Loc["Vendor.Login.Password"] - + - + - } - - @Loc["Vendor.Login.Password"] - - - - - - - - - - + + + + + + + @Loc["Vendor.Login.RememberMe"] - @Loc["Vendor.Login.RememberMe"] + @Loc["Vendor.Login.ForgotPassword"] - @Loc["Vendor.Login.ForgotPassword"] + @if (Model.DisplayCaptcha) + { + + + + } - @if (Model.DisplayCaptcha) - { - - - - } - - + + } + @if (Model.SignedInWithoutAccess) + { + @Loc["Admin.Login.SignedInWithoutAccess"] + + @Loc["Vendor.Header.Logout"] + + } + @if (Model.ExternalSignInUnavailable) + { + @Loc["Account.Login.ExternalSignInUnavailable"] + } @foreach (var signIn in Model.ExternalSignIn) { diff --git a/src/Web/Grand.Web.Vendor/Controllers/HomeController.cs b/src/Web/Grand.Web.Vendor/Controllers/HomeController.cs index 3cdcb99f1..6cc197332 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/HomeController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/HomeController.cs @@ -1,4 +1,3 @@ -using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Web.AdminShared.Controllers; @@ -19,9 +18,8 @@ public class HomeController : BaseHomeController public HomeController( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, ILogger logger) - : base(countryService, translationService, authenticationService) + : base(countryService, translationService) { _logger = logger; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 41dc6eb34..17a136263 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 9dda9a358..255679933 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 @@ -21,6 +21,12 @@ Signing in is possible only through the identity provider, which cannot be reached now. Try again later or contact the store. + + You are signed in, but this account cannot open this page. Sign out to use another account. + + + You are already signed in. If this account cannot open this panel, sign out to use another account. + Sign in with {0} diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs index 08b64e22c..353491b7c 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs @@ -6,6 +6,7 @@ using Grand.Domain.Customers; using Grand.Domain.Messages; using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; using Grand.Web.Commands.Models.Customers; using Grand.Mediator; @@ -19,6 +20,7 @@ public class UpdateCustomerInfoCommandHandler : IRequestHandler Handle(UpdateCustomerInfoCommand request, CancellationToken cancellationToken) @@ -53,7 +57,7 @@ await _customerService.UpdateCustomerField(request.Customer, x => x.Username, //email if (!request.Customer.Email.Equals(request.Model.Email.Trim(), StringComparison.OrdinalIgnoreCase) && - _customerSettings.AllowUsersToChangeEmail) + _customerSettings.AllowUsersToChangeEmail && !_loginConfig.ProviderOnly) { //change email request.Customer.Email = request.Model.Email.Trim(); diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index d3e4a7655..8738e8d07 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -20,6 +20,7 @@ using Grand.Web.Common.Security.Authorization; using Grand.Web.Common.Security.Captcha; using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Common.Security.Oidc; using Grand.Web.Extensions; using Grand.Web.Features.Models.Common; using Grand.Web.Features.Models.Customers; @@ -184,16 +185,53 @@ public virtual async Task Courses() [PublicStore(true)] [ClosedStore(true)] [IgnoreApi] - public virtual IActionResult Login(bool? checkoutAsGuest) + public virtual async Task Login(bool? checkoutAsGuest, string returnUrl, + [FromServices] OidcLoginConfig oidcLoginConfig, + [FromServices] IExternalAuthenticationService externalAuthenticationService) { var model = new LoginModel { UsernamesEnabled = _customerSettings.UsernamesEnabled, CheckoutAsGuest = checkoutAsGuest.GetValueOrDefault(), DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage }; + if (!oidcLoginConfig.ProviderOnly) + return View(model); + + //a signed-in account the store does not let through (e.g. no "Public store. Allow navigation") is sent here: + //the provider would sign it in again and return to the same page (a loop), so offer the sign-out instead + if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) + { + model.SignedInWithoutAccess = true; + return View(model); + } + + //the identity provider is the only sign-in: go there, or say it cannot be reached (never redirect back here) + var signInUrl = await ProviderSignInUrl(externalAuthenticationService, returnUrl); + if (signInUrl != null) + return Redirect(signInUrl); + model.ExternalSignInUnavailable = true; return View(model); } + /// + /// The sign-in address of the first active provider that can sign in, or null. Only a local return url is passed on. + /// + private async Task ProviderSignInUrl(IExternalAuthenticationService externalAuthenticationService, + string returnUrl) + { + var local = !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : null; + var providers = externalAuthenticationService.LoadActiveAuthenticationProviders( + _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore); + foreach (var provider in providers.OfType()) + { + var path = await provider.GetSignInPath(local); + if (!string.IsNullOrEmpty(path)) + return HttpContext.Request.PathBase + path; + } + + return null; + } + [HttpPost] //available even when navigation is not allowed [PublicStore(true)] @@ -336,7 +374,8 @@ private async Task SignInAction(Customer customer, bool createPer [PublicStore(true)] [IgnoreApi] public virtual async Task Logout( - [FromServices] StoreInformationSettings storeInformationSettings) + [FromServices] StoreInformationSettings storeInformationSettings, + [FromServices] IProviderSignOut providerSignOut) { if (_contextAccessor.WorkContext.OriginalCustomerIfImpersonated != null) { @@ -352,13 +391,13 @@ public virtual async Task Logout( //raise event await _mediator.Publish(new CustomerLoggedOutEvent(_contextAccessor.WorkContext.CurrentCustomer)); - //standard logout - await _authenticationService.SignOut(); + //standard logout, and the provider's end-session URL when the sign-in came from the identity provider + var providerUrl = await providerSignOut.SignOut(); //Cookie if (storeInformationSettings.DisplayCookieInformation) TempData["Grand.IgnoreCookieInformation"] = true; - return RedirectToRoute("HomePage"); + return providerUrl != null ? Redirect(providerUrl) : RedirectToRoute("HomePage"); } #endregion @@ -368,8 +407,11 @@ public virtual async Task Logout( //available even when navigation is not allowed [PublicStore(true)] [HttpGet] - public virtual IActionResult PasswordRecovery() + public virtual IActionResult PasswordRecovery([FromServices] OidcLoginConfig oidcLoginConfig) { + if (oidcLoginConfig.ProviderOnly) + return RedirectToRoute("Login"); + var model = new PasswordRecoveryModel { DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage }; @@ -378,8 +420,12 @@ public virtual IActionResult PasswordRecovery() [HttpPost] [PublicStore(true)] - public virtual async Task> PasswordRecovery(PasswordRecoveryModel model) + public virtual async Task> PasswordRecovery(PasswordRecoveryModel model, + [FromServices] OidcLoginConfig oidcLoginConfig) { + if (oidcLoginConfig.ProviderOnly) + return RedirectToRoute("Login"); + if (!ModelState.IsValid) return View(model); var customer = await _customerService.GetCustomerByEmail(model.Email, CustomerStoreId); @@ -397,8 +443,12 @@ await _mediator.Send(new PasswordRecoverySendCommand { [HttpGet] [PublicStore(true)] - public virtual async Task> PasswordRecoveryConfirm(string token, string email) + public virtual async Task> PasswordRecoveryConfirm(string token, string email, + [FromServices] OidcLoginConfig oidcLoginConfig) { + if (oidcLoginConfig.ProviderOnly) + return RedirectToRoute("Login"); + var customer = await _customerService.GetCustomerByEmail(email, CustomerStoreId); if (customer == null) return RedirectToRoute("HomePage"); @@ -411,8 +461,12 @@ public virtual async Task> PasswordRe [HttpPost] //available even when navigation is not allowed [PublicStore(true)] - public virtual async Task PasswordRecoveryConfirm(PasswordRecoveryConfirmModel model) + public virtual async Task PasswordRecoveryConfirm(PasswordRecoveryConfirmModel model, + [FromServices] OidcLoginConfig oidcLoginConfig) { + if (oidcLoginConfig.ProviderOnly) + return RedirectToRoute("Login"); + if (!ModelState.IsValid) return View(model); var customer = await _customerService.GetCustomerByEmail(model.Email, CustomerStoreId); @@ -437,15 +491,32 @@ await _customerManagerService.ChangePassword(new ChangePasswordRequest(model.Ema [HttpGet] public virtual async Task> Register( [FromServices] IExternalRegistrationContext externalRegistrationContext, - [FromServices] IExternalAuthenticationService externalAuthenticationService) + [FromServices] IExternalAuthenticationService externalAuthenticationService, + [FromServices] OidcLoginConfig oidcLoginConfig, + string returnUrl = null) { - //check whether registration is allowed - if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled) + //check whether registration is allowed; with AutoProvision the provider creates accounts whatever the store's type + if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled && + oidcLoginConfig is not { ProviderOnly: true, AutoProvision: true }) return RedirectToRoute("RegisterResult", new { resultId = (int)UserRegistrationType.Disabled }); //check if customer is registered. if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) return RedirectToRoute("HomePage"); + var pendingIdentity = await externalRegistrationContext.GetPending(); + //AutoProvision never leaves a pending identity for the form: every visitor goes to the provider + if (oidcLoginConfig.ProviderOnly && (pendingIdentity == null || oidcLoginConfig.AutoProvision)) + { + //accounts come from the identity provider: send the visitor there, or say it cannot be reached + var signInUrl = await ProviderSignInUrl(externalAuthenticationService, returnUrl); + if (signInUrl != null) + return Redirect(signInUrl); + return View("Login", new LoginModel { + UsernamesEnabled = _customerSettings.UsernamesEnabled, + ExternalSignInUnavailable = true + }); + } + var model = await _mediator.Send(new GetRegister { Customer = _contextAccessor.WorkContext.CurrentCustomer, ExcludeProperties = false, @@ -453,7 +524,7 @@ public virtual async Task> Register( Store = _contextAccessor.StoreContext.CurrentStore }); - PrepareExternalRegistration(model, await externalRegistrationContext.GetPending(), externalAuthenticationService); + PrepareExternalRegistration(model, pendingIdentity, externalAuthenticationService); return View(model); } @@ -478,7 +549,8 @@ private static void PrepareExternalRegistration(RegisterModel model, ExternalReg [PublicStore(true)] public virtual async Task Register(RegisterModel model, string returnUrl, [FromServices] IExternalRegistrationContext externalRegistrationContext, - [FromServices] IExternalAuthenticationService externalAuthenticationService) + [FromServices] IExternalAuthenticationService externalAuthenticationService, + [FromServices] OidcLoginConfig oidcLoginConfig) { //check whether registration is allowed if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled) @@ -488,6 +560,9 @@ public virtual async Task Register(RegisterModel model, string re if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) return RedirectToRoute("HomePage"); var pending = await externalRegistrationContext.GetPending(); + //the identity provider is the only way to an account, and only through its verified e-mail + if (oidcLoginConfig.ProviderOnly && pending is not { EmailVerified: true }) + return RedirectToRoute("Login"); if (pending != null) model.Email = pending.Email; var registrationType = pending == null @@ -625,10 +700,12 @@ await _messageProviderService.SendCustomerWelcomeMessage(_contextAccessor.WorkCo [PublicStore(true)] [HttpGet] public virtual async Task RegisterExternalCancel( - [FromServices] IExternalRegistrationContext externalRegistrationContext) + [FromServices] IExternalRegistrationContext externalRegistrationContext, + [FromServices] OidcLoginConfig oidcLoginConfig) { await externalRegistrationContext.Clear(); - return RedirectToRoute("Register"); + //in provider-only mode Register goes back to the provider, which returns to the form at once: cancel goes home + return RedirectToRoute(oidcLoginConfig.ProviderOnly ? "HomePage" : "Register"); } //available even when navigation is not allowed @@ -774,13 +851,16 @@ await _mediator.Send(new UpdateCustomerInfoCommand { [HttpPost] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public virtual async Task RemoveExternalAssociation(string id, - [FromServices] IExternalAuthenticationService openAuthenticationService) + [FromServices] IExternalAuthenticationService openAuthenticationService, + [FromServices] OidcLoginConfig oidcLoginConfig) { //ensure it's our record var ear = (await openAuthenticationService.GetExternalIdentifiers(_contextAccessor.WorkContext.CurrentCustomer)) .FirstOrDefault(x => x.Id == id); - if (ear == null) + //the identity provider owns the OpenID Connect connection in provider-only mode + if (ear == null || (oidcLoginConfig.ProviderOnly && + ear.ProviderSystemName == OidcResourceServerDefaults.ProviderSystemName)) return Json(new { redirect = Url.Action("Info") }); @@ -1003,8 +1083,11 @@ public virtual async Task UserAgreement(Guid orderItemId) [HttpGet] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public virtual async Task ChangePassword() + public virtual async Task ChangePassword([FromServices] OidcLoginConfig oidcLoginConfig) { + if (oidcLoginConfig.ProviderOnly) + return RedirectToRoute("CustomerInfo"); + var model = new ChangePasswordModel { PasswordIsExpired = await _mediator.Send(new GetPasswordIsExpiredQuery { Customer = _contextAccessor.WorkContext.CurrentCustomer }) @@ -1015,8 +1098,12 @@ public virtual async Task ChangePassword() [HttpPost] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public virtual async Task ChangePassword(ChangePasswordModel model) + public virtual async Task ChangePassword(ChangePasswordModel model, + [FromServices] OidcLoginConfig oidcLoginConfig) { + if (oidcLoginConfig.ProviderOnly) + return RedirectToRoute("CustomerInfo"); + if (!ModelState.IsValid) return View(model); var changePasswordRequest = new ChangePasswordRequest(_contextAccessor.WorkContext.CurrentCustomer.Email, @@ -1038,23 +1125,26 @@ public virtual async Task ChangePassword(ChangePasswordModel mode [HttpGet] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public virtual IActionResult DeleteAccount() + public virtual IActionResult DeleteAccount([FromServices] OidcLoginConfig oidcLoginConfig) { if (!_customerSettings.AllowUsersToDeleteAccount) return RedirectToRoute("CustomerInfo"); - var model = new DeleteAccountModel(); + var model = new DeleteAccountModel { PasswordRequired = !oidcLoginConfig.ProviderOnly }; return View(model); } [HttpPost] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public virtual async Task DeleteAccount(DeleteAccountModel model) + public virtual async Task DeleteAccount(DeleteAccountModel model, + [FromServices] OidcLoginConfig oidcLoginConfig) { if (!_customerSettings.AllowUsersToDeleteAccount) return RedirectToRoute("CustomerInfo"); + //the password field is not offered (nor required) in provider-only mode + model.PasswordRequired = !oidcLoginConfig.ProviderOnly; if (!ModelState.IsValid) return View(model); //delete account @@ -1080,9 +1170,9 @@ await _mediator.Send(new DeleteAccountCommand { [IgnoreApi] [HttpGet] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public async Task EnableTwoFactorAuthenticator() + public async Task EnableTwoFactorAuthenticator([FromServices] OidcLoginConfig oidcLoginConfig) { - if (!_customerSettings.TwoFactorAuthenticationEnabled) + if (oidcLoginConfig.ProviderOnly || !_customerSettings.TwoFactorAuthenticationEnabled) return RedirectToRoute("CustomerInfo"); if (_contextAccessor.WorkContext.CurrentCustomer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled)) @@ -1101,9 +1191,10 @@ public async Task EnableTwoFactorAuthenticator() [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public async Task EnableTwoFactorAuthenticator( CustomerInfoModel.TwoFactorAuthenticationModel model, - [FromServices] ITwoFactorAuthenticationService twoFactorAuthenticationService) + [FromServices] ITwoFactorAuthenticationService twoFactorAuthenticationService, + [FromServices] OidcLoginConfig oidcLoginConfig) { - if (!_customerSettings.TwoFactorAuthenticationEnabled) + if (oidcLoginConfig.ProviderOnly || !_customerSettings.TwoFactorAuthenticationEnabled) return RedirectToRoute("CustomerInfo"); if (_contextAccessor.WorkContext.CurrentCustomer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled)) @@ -1139,9 +1230,9 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto [IgnoreApi] [HttpGet] [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public async Task DisableTwoFactorAuthenticator() + public async Task DisableTwoFactorAuthenticator([FromServices] OidcLoginConfig oidcLoginConfig) { - if (!_customerSettings.TwoFactorAuthenticationEnabled) + if (oidcLoginConfig.ProviderOnly || !_customerSettings.TwoFactorAuthenticationEnabled) return RedirectToRoute("CustomerInfo"); if (!_contextAccessor.WorkContext.CurrentCustomer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled)) @@ -1164,9 +1255,10 @@ public async Task