From 75a6d2562c049d58cadb1ef72123ef861aabb156 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 13 Oct 2026 23:11:08 +0200 Subject: [PATCH 01/13] Storefront: provider-only sign-in sends login, registration and password recovery to the identity provider --- .../AccountControllerExternalRegisterTests.cs | 26 +- .../AccountControllerProviderOnlyTests.cs | 197 ++++++++++++++ .../Controllers/AccountController.cs | 76 +++++- .../Grand.Web/Models/Customer/LoginModel.cs | 2 + src/Web/Grand.Web/Views/Account/Login.cshtml | 242 +++++++++--------- 5 files changed, 407 insertions(+), 136 deletions(-) create mode 100644 src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs index 41afa9a643..84cd7f97d9 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 0000000000..083400570b --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs @@ -0,0 +1,197 @@ +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.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 OidcLoginConfig _providerOnly; + 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(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = "g1" }); + 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()); + var groups = new Mock(); + groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + + _controller = new AccountController(new Mock().Object, + new Mock().Object, context.Object, new Mock().Object, + groups.Object, new Mock().Object, new Mock().Object, + mediator.Object, new Mock().Object, new CaptchaSettings(), + new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }, 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_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_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 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 void PasswordRecovery_Mixed_ShowsForm() + { + var result = _controller.PasswordRecovery(new OidcLoginConfig()); + Assert.IsInstanceOfType(result); + } +} diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index d3e4a76551..163b7b81d5 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -184,16 +184,42 @@ 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); + + //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); + var provider = providers.OfType().FirstOrDefault(); + if (provider == null) + return null; + var path = await provider.GetSignInPath(local); + return string.IsNullOrEmpty(path) ? null : HttpContext.Request.PathBase + path; + } + [HttpPost] //available even when navigation is not allowed [PublicStore(true)] @@ -368,8 +394,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 +407,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 +430,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 +448,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,7 +478,9 @@ 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) @@ -446,6 +489,19 @@ public virtual async Task> Register( //check if customer is registered. if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) return RedirectToRoute("HomePage"); + var pendingIdentity = await externalRegistrationContext.GetPending(); + if (oidcLoginConfig.ProviderOnly && pendingIdentity == null) + { + //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 +509,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 +534,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 +545,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 diff --git a/src/Web/Grand.Web/Models/Customer/LoginModel.cs b/src/Web/Grand.Web/Models/Customer/LoginModel.cs index d32410a0e0..049268d2e8 100644 --- a/src/Web/Grand.Web/Models/Customer/LoginModel.cs +++ b/src/Web/Grand.Web/Models/Customer/LoginModel.cs @@ -27,6 +27,8 @@ public class LoginModel : BaseModel [GrandResourceDisplayName("Account.Login.Fields.RememberMe")] public bool RememberMe { get; set; } + public bool ExternalSignInUnavailable { get; set; } + public bool DisplayCaptcha { get; set; } public ICaptchaValidModel Captcha { get; set; } = new CaptchaModel(); } \ No newline at end of file diff --git a/src/Web/Grand.Web/Views/Account/Login.cshtml b/src/Web/Grand.Web/Views/Account/Login.cshtml index d5fc6570aa..9591fe83ea 100644 --- a/src/Web/Grand.Web/Views/Account/Login.cshtml +++ b/src/Web/Grand.Web/Views/Account/Login.cshtml @@ -9,142 +9,154 @@ //register URL with return URL (if specified) var registerUrl = Url.RouteUrl("Register", new { returnUrl = Context.Request.Query["returnUrl"] }); } -