From caded6a8803968072294a54808d0fad9ff5c1b11 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 14 Oct 2026 14:07:22 +0200 Subject: [PATCH 1/2] Panels: the two-factor step after a password sign-in stays on the panel A panel password sign-in that needs a second factor stored the storefront's session key and redirected to the storefront's two-factor page, so the code was entered on the storefront and the user ended up there; the panels' own two-factor action read a key nobody set. The panel now keeps its own key and sends the user to its own TwoFactorAuthorization action (Admin, Store and Vendor). --- .../LoginControllerTwoFactorTests.cs | 129 ++++++++++++++++++ .../Controllers/BaseLoginController.cs | 16 ++- 2 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs new file mode 100644 index 000000000..180edda39 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs @@ -0,0 +1,129 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Models.Common; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class LoginControllerTwoFactorTests +{ + private sealed class TestSession : ISession + { + private readonly Dictionary _data = new(); + public bool IsAvailable => true; + public string Id => "s"; + public IEnumerable Keys => _data.Keys; + public void Clear() => _data.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _data.Remove(key); + public void Set(string key, byte[] value) => _data[key] = value; + public bool TryGetValue(string key, out byte[] value) => _data.TryGetValue(key, out value!); + } + + private readonly Customer _customer = new() { Id = "c1", Email = "admin@shop.com" }; + private Mock _manager = null!; + private Mock _authentication = null!; + private TestSession _session = null!; + private LoginController _controller = null!; + + [TestInitialize] + public void Setup() + { + _customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + _manager = new Mock(); + _manager.Setup(x => x.LoginCustomer("admin@shop.com", "pwd", It.IsAny())) + .ReturnsAsync(CustomerLoginResults.RequiresTwoFactor); + var customers = new Mock(); + customers.Setup(x => x.GetCustomerByEmail("admin@shop.com", It.IsAny())).ReturnsAsync(_customer); + _authentication = new Mock(); + var settings = new CustomerSettings { + TwoFactorAuthenticationEnabled = true, + TwoFactorAuthenticationType = TwoFactorAuthenticationType.AppVerification + }; + var translations = new Mock(); + translations.Setup(x => x.GetResource(It.IsAny())).Returns(key => key); + _controller = new LoginController(settings, new CaptchaSettings(), translations.Object, + _manager.Object, customers.Object, _authentication.Object, new Mock().Object, + new Mock().Object, new Mock().Object); + _session = new TestSession(); + _controller.ControllerContext = new ControllerContext { + HttpContext = new DefaultHttpContext { Session = _session }, + RouteData = new RouteData { Values = { ["area"] = "Admin" } } + }; + } + + private Task PasswordStep() => + _controller.Index(new LoginModel { Email = "admin@shop.com", Password = "pwd" }, + new Mock().Object); + + [TestMethod] + public async Task Password_RequiresTwoFactor_GoesToThePanelsOwnSecondStep() + { + var result = (RedirectToActionResult)await PasswordStep(); + + Assert.AreEqual("TwoFactorAuthorization", result.ActionName); + Assert.AreEqual("Login", result.ControllerName); + Assert.AreEqual("Admin", result.RouteValues!["area"]); + Assert.IsFalse(_session.Keys.Contains("RequiresTwoFactor"), "the storefront's key signs in on the storefront"); + } + + [TestMethod] + public async Task SecondStep_Get_ShowsTheForm() + { + await PasswordStep(); + + var result = await _controller.TwoFactorAuthorization(new Mock().Object); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task SecondStep_ValidCode_SignsInToThePanel() + { + await PasswordStep(); + var twoFactor = new Mock(); + twoFactor.Setup(x => x.AuthenticateTwoFactor(It.IsAny(), "123456", _customer, It.IsAny())) + .ReturnsAsync(true); + + var result = (RedirectToRouteResult)await _controller.TwoFactorAuthorization("123456", twoFactor.Object); + + Assert.AreEqual("AdminIndex", result.RouteName); + _authentication.Verify(x => x.SignIn(_customer, false), Times.Once); + Assert.IsFalse(_session.Keys.Any()); + } + + [TestMethod] + public async Task SecondStep_WrongCode_NotSignedIn() + { + await PasswordStep(); + + var result = await _controller.TwoFactorAuthorization("000000", new Mock().Object); + + Assert.IsInstanceOfType(result); + _authentication.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SecondStep_WithoutPasswordStep_BackToThePanelLogin() + { + var result = (RedirectToRouteResult)await _controller.TwoFactorAuthorization("123456", + new Mock().Object); + + Assert.AreEqual("AdminLogin", result.RouteName); + _authentication.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs index aab0f6e03..87fcf4b75 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs @@ -20,6 +20,9 @@ namespace Grand.Web.AdminShared.Controllers; public abstract class BaseLoginController : BaseController { + //the account waiting for its second factor on a panel; never the storefront's key, whose page signs in on the storefront + private const string TwoFactorSessionKey = "AdminRequiresTwoFactor"; + private readonly IGrandAuthenticationService _authenticationService; private readonly CaptchaSettings _captchaSettings; private readonly ICustomerManagerService _customerManagerService; @@ -116,8 +119,9 @@ await _customerManagerService.LoginCustomer( case CustomerLoginResults.RequiresTwoFactor: { var userName = _customerSettings.UsernamesEnabled ? model.Username : model.Email; - HttpContext.Session.SetString("RequiresTwoFactor", userName!); - return RedirectToRoute("TwoFactorAuthorization"); + //the panel's own second step: the storefront's page would sign in on the storefront + HttpContext.Session.SetString(TwoFactorSessionKey, userName!); + return RedirectToAction(nameof(TwoFactorAuthorization), "Login", new { area = GetCurrentArea() }); } } } @@ -230,7 +234,7 @@ public async Task TwoFactorAuthorization( if (!_customerSettings.TwoFactorAuthenticationEnabled) return RedirectToRoute($"{GetCurrentArea()}Login"); - var username = HttpContext.Session.GetString("AdminRequiresTwoFactor"); + var username = HttpContext.Session.GetString(TwoFactorSessionKey); if (string.IsNullOrEmpty(username)) return RedirectToRoute($"{GetCurrentArea()}Login"); @@ -262,9 +266,9 @@ public async Task TwoFactorAuthorization(string token, if (!_customerSettings.TwoFactorAuthenticationEnabled) return RedirectToRoute($"{GetCurrentArea()}Login"); - var username = HttpContext.Session.GetString("AdminRequiresTwoFactor"); + var username = HttpContext.Session.GetString(TwoFactorSessionKey); if (string.IsNullOrEmpty(username)) - return RedirectToRoute("HomePage"); + return RedirectToRoute($"{GetCurrentArea()}Login"); var customer = _customerSettings.UsernamesEnabled ? await _customerService.GetCustomerByUsername(username) @@ -284,7 +288,7 @@ public async Task TwoFactorAuthorization(string token, _customerSettings.TwoFactorAuthenticationType)) { //remove session - HttpContext.Session.Remove("AdminRequiresTwoFactor"); + HttpContext.Session.Remove(TwoFactorSessionKey); //sign in return await SignInAction(customer, false); From 6b5cc3ae2620d875ef26fdd05e3ab43b1f7ff313 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 14 Oct 2026 18:21:17 +0200 Subject: [PATCH 2/2] Tests: panel two-factor step with the sign-in surface and provider-only Index parameters --- .../Controllers/LoginControllerTwoFactorTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs index 180edda39..d86e11308 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs @@ -6,6 +6,7 @@ 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; @@ -45,7 +46,7 @@ public void Setup() { _customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); _manager = new Mock(); - _manager.Setup(x => x.LoginCustomer("admin@shop.com", "pwd", It.IsAny())) + _manager.Setup(x => x.LoginCustomer("admin@shop.com", "pwd", It.IsAny(), It.IsAny())) .ReturnsAsync(CustomerLoginResults.RequiresTwoFactor); var customers = new Mock(); customers.Setup(x => x.GetCustomerByEmail("admin@shop.com", It.IsAny())).ReturnsAsync(_customer); @@ -68,7 +69,7 @@ public void Setup() private Task PasswordStep() => _controller.Index(new LoginModel { Email = "admin@shop.com", Password = "pwd" }, - new Mock().Object); + new Mock().Object, new OidcLoginConfig()); [TestMethod] public async Task Password_RequiresTwoFactor_GoesToThePanelsOwnSecondStep()