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..d86e11308 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerTwoFactorTests.cs @@ -0,0 +1,130 @@ +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.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.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(), 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, new OidcLoginConfig()); + + [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);