From 0c37cf0e798877cfa0f04016ca89ebde12a20f98 Mon Sep 17 00:00:00 2001 From: "lee.dalchow" Date: Wed, 18 May 2022 23:16:11 +0100 Subject: [PATCH 1/8] Front-end changes for login with email code functionality. Allows the user to login with an e-mail code instead of a password. (Back-end not yet implemented) --- .../App_Data/Resources/DefaultLanguage.xml | 24 ++++++++ .../Controllers/AccountController.cs | 49 ++++++++++++++- .../Grand.Web/Endpoints/EndpointProvider.cs | 5 ++ .../Customer/LoginWithEmailCodeModel.cs | 16 +++++ src/Web/Grand.Web/Views/Account/Login.cshtml | 3 + .../Views/Account/LoginWithEmailCode.cshtml | 59 +++++++++++++++++++ 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs create mode 100644 src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 78d505aac..c3db113e7 100644 --- a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml +++ b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml @@ -516,6 +516,9 @@ Forgot password? + + Login with E-mail Code + Log in @@ -582,6 +585,24 @@ Product + + Login With E-mail Code + + + Your email address + + + Send Email + + + Please enter your email address below. You will receive a link to login to your account without having to enter your password. + + + Login email has been sent to you. + + + Email not found. + Password recovery @@ -16533,6 +16554,9 @@ Password Recovery + + Login With E-mail Code + Ask question diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index b167d4737..1995a4a50 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -271,10 +271,57 @@ public virtual async Task Logout([FromServices] StoreInformationS #endregion - #region Password recovery + #region Login With E-mail Code //available even when navigation is not allowed [PublicStore(true)] + public virtual IActionResult LoginWithEmailCode() + { + var model = new LoginWithEmailCodeModel(); + model.DisplayCaptcha = _captchaSettings.Enabled; + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [ValidateCaptcha] + [PublicStore(true)] + public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) + { + //validate CAPTCHA + if (_captchaSettings.Enabled && !captchaValid) + { + ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); + } + + if (ModelState.IsValid) + { + var customer = await _customerService.GetCustomerByEmail(model.Email); + if (customer != null && customer.Active && !customer.Deleted) + { + // TODO - Actually send the e-mail! + //await _mediator.Send(new PasswordRecoverySendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model }); + + model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); + model.Send = true; + } + else + { + model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); + } + + return View(model); + } + + return View(model); + } + + #endregion + + #region Password recovery + + //available even when navigation is not allowed + [PublicStore(true)] public virtual IActionResult PasswordRecovery() { var model = new PasswordRecoveryModel(); diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 34065f149..61829a6bb 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -113,6 +113,11 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st pattern + "account/checkusernameavailability", new { controller = "Account", action = "CheckUsernameAvailability" }); + //Login with email code + endpointRouteBuilder.MapControllerRoute("LoginWithEmailCode", + pattern + "LoginWithEmailCode", + new { controller = "Account", action = "LoginWithEmailCode" }); + //passwordrecovery endpointRouteBuilder.MapControllerRoute("PasswordRecovery", pattern + "passwordrecovery", diff --git a/src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs b/src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs new file mode 100644 index 000000000..bfa1dd347 --- /dev/null +++ b/src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs @@ -0,0 +1,16 @@ +using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.Models; +using System.ComponentModel.DataAnnotations; + +namespace Grand.Web.Models.Customer +{ + public partial class LoginWithEmailCodeModel : BaseModel + { + [DataType(DataType.EmailAddress)] + [GrandResourceDisplayName("Account.LoginWithEmailCode.Email")] + public string Email { get; set; } + public string Result { get; set; } + public bool Send { get; set; } + public bool DisplayCaptcha { get; set; } + } +} \ 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 1df4b2e46..72cbb5210 100644 --- a/src/Web/Grand.Web/Views/Account/Login.cshtml +++ b/src/Web/Grand.Web/Views/Account/Login.cshtml @@ -79,6 +79,9 @@ @Loc["Account.Login.ForgotPassword"] + @if (Model.DisplayCaptcha) { diff --git a/src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml b/src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml new file mode 100644 index 000000000..538e8d494 --- /dev/null +++ b/src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml @@ -0,0 +1,59 @@ +@model LoginWithEmailCodeModel +@using Grand.Web.Models.Customer; +@inject IPageHeadBuilder pagebuilder +@{ + Layout = "_SingleColumn"; + + //title + pagebuilder.AddTitleParts(Loc["Title.LoginWithEmailCode"]); +} +
+

@Loc["Account.LoginWithEmailCode"]

+ @if (!String.IsNullOrEmpty(Model.Result)) + { +
+ @Model.Result +
+ } + @if (!Model.Send) + { + +
+ +
+
+ + + + {{ errors[0] }} + + +
+ @if (Model.DisplayCaptcha) + { +
+
+ +
+
+ } +
+
+ +
+
+ +
+ @Loc["Account.LoginWithEmailCode.Tooltip"] +
+
+
+ } +
+ \ No newline at end of file From f5696f2e11d0376c510f076aafa9e4fab567b169 Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Sat, 21 May 2022 23:45:46 +0100 Subject: [PATCH 2/8] Added backend for LoginWithEmailCode --- .../Customers/ICustomerManagerService.cs | 8 + .../Messages/IMessageProviderService.cs | 9 + .../Messages/DotLiquidDrops/LiquidCustomer.cs | 5 + .../Services/CustomerManagerService.cs | 61 +++++ .../Services/MessageProviderService.cs | 13 + .../Services/MessageTemplateNames.cs | 1 + .../InstallDataMessageTemplates.cs | 8 + .../Installation/InstallDataRobotsTxt.cs | 1 + src/Core/Grand.Domain/Customers/Customer.cs | 5 + .../Customers/SystemCustomerFieldNames.cs | 3 + .../App_Data/Resources/DefaultLanguage.xml | 3 + .../Customers/EmailCodeSendCommandHandler.cs | 46 ++++ .../Models/Customers/EmailCodeSendCommand.cs | 22 ++ .../Controllers/AccountController.cs | 234 ++++++++++-------- .../Grand.Web/Endpoints/EndpointProvider.cs | 5 + 15 files changed, 323 insertions(+), 101 deletions(-) create mode 100644 src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs create mode 100644 src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs index 17917a8a5..cb9d72bab 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs @@ -16,6 +16,14 @@ public partial interface ICustomerManagerService /// Result Task LoginCustomer(string usernameOrEmail, string password); + /// + /// Login customer with E-mail Code + /// + /// UserId of the record + /// loginCode provided in e-mail + /// Result + Task LoginCustomerWithEmailCode(string userId, string loginCode); + /// /// Register customer /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs b/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs index 23b422cc5..4f47c8565 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs @@ -52,6 +52,15 @@ public partial interface IMessageProviderService /// Queued email identifier Task SendCustomerPasswordRecoveryMessage(Customer customer, Store store, string languageId); + /// + /// Sends E-mail login code to the customer + /// + /// Customer instance + /// Store + /// Message language identifier + /// Queued email identifier + Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId); + /// /// Sends a new customer note added notification to a customer /// diff --git a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs index 2ca97b3a9..8d054b59d 100644 --- a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs @@ -110,6 +110,11 @@ public string PasswordRecoveryURL get { return string.Format("{0}/passwordrecovery/confirm?token={1}&email={2}", url, _customer.GetUserFieldFromEntity(SystemCustomerFieldNames.PasswordRecoveryToken), WebUtility.UrlEncode(_customer.Email)); } } + public string LoginCodeURL + { + get { return string.Format("{0}/LoginWithEmailCode/?userId={1}&loginCode={2}", url, _customer.Id, _customer.LoginCode); } + } + public string AccountActivationURL { get { return string.Format("{0}/account/activation?token={1}&email={2}", url, _customer.GetUserFieldFromEntity(SystemCustomerFieldNames.AccountActivationToken), WebUtility.UrlEncode(_customer.Email)); ; } diff --git a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs index 204adf6a5..577680b0c 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs @@ -139,6 +139,67 @@ public virtual async Task LoginCustomer(string usernameOrE return CustomerLoginResults.Successful; } + /// + /// Login customer with E-mail Code + /// + /// UserId of the record + /// loginCode provided in e-mail + /// Result + public virtual async Task LoginCustomerWithEmailCode(string userId, string loginCode) + { + var customer = await _customerService.GetCustomerById(userId); + + if (customer == null) + return CustomerLoginResults.CustomerNotExist; + if (customer.Deleted) + return CustomerLoginResults.Deleted; + if (!customer.Active) + return CustomerLoginResults.NotActive; + if (!await _groupService.IsRegistered(customer)) + return CustomerLoginResults.NotRegistered; + + if (customer.CannotLoginUntilDateUtc.HasValue && customer.CannotLoginUntilDateUtc.Value > DateTime.UtcNow) + return CustomerLoginResults.LockedOut; + + if (string.IsNullOrEmpty(loginCode)) + return CustomerLoginResults.WrongPassword; + + // Hash loginCode & generate current timestamp + string hashedLoginCode = _encryptionService.CreatePasswordHash(loginCode, customer.PasswordSalt, _customerSettings.HashedPasswordFormat); + long curTimeStamp = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds(); + + // Get saved loginCode & get expiry timestamp + string savedHashedLoginCode = await _userFieldService.GetFieldsForEntity(customer, SystemCustomerFieldNames.EmailLoginToken); + long savedHashedLoginCodeExpiry = await _userFieldService.GetFieldsForEntity(customer, SystemCustomerFieldNames.EmailLoginTokenExpiry); + + + var isValid = hashedLoginCode == savedHashedLoginCode && curTimeStamp < savedHashedLoginCodeExpiry; + + if (!isValid) + { + //wrong password or expired + // Do not increase the FailedLoginAttempts as it seems unlikely a brute force will success to guess a GUID within the 10 minute expiry period. + + await _customerService.UpdateCustomerLastLoginDate(customer); + return CustomerLoginResults.WrongPassword; // or expired + } + + //2fa required + if (customer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled) && _customerSettings.TwoFactorAuthenticationEnabled) + return CustomerLoginResults.RequiresTwoFactor; + + //save last login date + customer.FailedLoginAttempts = 0; + customer.CannotLoginUntilDateUtc = null; + customer.LastLoginDateUtc = DateTime.UtcNow; + await _customerService.UpdateCustomerLastLoginDate(customer); + + // Remove code used to login so the link can't be used twice. We do this by setting the expiry timestamp to 0 + await _userFieldService.SaveField(customer, SystemCustomerFieldNames.EmailLoginTokenExpiry, 0); + + return CustomerLoginResults.Successful; + } + /// /// Register customer /// diff --git a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs index 491cca6cd..4b85e53a7 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs @@ -219,6 +219,19 @@ public virtual async Task SendCustomerPasswordRecoveryMessage(Customer cust return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerPasswordRecovery); } + /// + /// Sends an e-mail login link to the customer + /// + /// Customer + /// Store + /// Message language identifier + /// Queued email identifier + public virtual async Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId) + { + return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerEmailLoginCode); + } + + /// /// Sends a new customer note added notification to a customer /// diff --git a/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs b/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs index 8232277c6..d4b28d6ba 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs @@ -7,6 +7,7 @@ public class MessageTemplateNames public const string CustomerWelcome = "Customer.WelcomeMessage"; public const string CustomerEmailValidation = "Customer.EmailValidationMessage"; public const string CustomerPasswordRecovery = "Customer.PasswordRecovery"; + public const string CustomerEmailLoginCode = "Customer.EmailLoginCode"; public const string CustomerNewCustomerNote = "Customer.NewCustomerNote"; public const string CustomerEmailTokenValidationMessage = "Customer.EmailTokenValidationMessage"; diff --git a/src/Business/Grand.Business.System/Services/Installation/InstallDataMessageTemplates.cs b/src/Business/Grand.Business.System/Services/Installation/InstallDataMessageTemplates.cs index 19be10ce3..1b0c938a0 100644 --- a/src/Business/Grand.Business.System/Services/Installation/InstallDataMessageTemplates.cs +++ b/src/Business/Grand.Business.System/Services/Installation/InstallDataMessageTemplates.cs @@ -271,6 +271,14 @@ protected virtual async Task InstallMessageTemplates() IsActive = true, EmailAccountId = eaGeneral.Id, }, + new MessageTemplate + { + Name = "Customer.EmailLoginCode", + Subject = "Login to {{Store.Name}}", + Body = "{{Store.Name}}
\r\n
\r\n To login to {{Store.Name}} click here.
\r\n
\r\n {{Store.Name}}", + IsActive = true, + EmailAccountId = eaGeneral.Id, + }, new MessageTemplate { Name = "Customer.WelcomeMessage", diff --git a/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs b/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs index fcce56131..373cd2082 100644 --- a/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs +++ b/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs @@ -42,6 +42,7 @@ protected virtual async Task InstallDataRobotsTxt( Disallow: /order/* Disallow: /orderdetails Disallow: /passwordrecovery/confirm +Disallow: /LoginWithEmailCode Disallow: /popupinteractiveform Disallow: /register/* Disallow: /merchandisereturn diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index f04b1139e..1e9c34367 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -51,6 +51,11 @@ public Customer() /// public string PasswordSalt { get; set; } + /// + /// Gets or sets the login token + /// + public string LoginCode { get; set; } + /// /// Gets or sets the admin comment /// diff --git a/src/Core/Grand.Domain/Customers/SystemCustomerFieldNames.cs b/src/Core/Grand.Domain/Customers/SystemCustomerFieldNames.cs index a63bf55fc..d84107fc5 100644 --- a/src/Core/Grand.Domain/Customers/SystemCustomerFieldNames.cs +++ b/src/Core/Grand.Domain/Customers/SystemCustomerFieldNames.cs @@ -24,6 +24,9 @@ public static partial class SystemCustomerFieldNames public static string DiscountCoupons { get { return "DiscountCoupons"; } } public static string GiftVoucherCoupons { get { return "GiftVoucherCoupons"; } } public static string UrlReferrer { get { return "UrlReferrer"; } } + public static string EmailLoginToken { get { return "EmailLoginToken"; } } + public static string EmailLoginTokenExpiry { get { return "EmailLoginTokenExpiry"; } } + public static string PasswordRecoveryToken { get { return "PasswordRecoveryToken"; } } public static string PasswordRecoveryTokenDateGenerated { get { return "PasswordRecoveryTokenDateGenerated"; } } public static string AccountActivationToken { get { return "AccountActivationToken"; } } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index c3db113e7..fc76eace5 100644 --- a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml +++ b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml @@ -540,6 +540,9 @@ The credentials provided are incorrect + + The login link provided has expired or has already been used. + No customer account found diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs new file mode 100644 index 000000000..d8aebeb5b --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs @@ -0,0 +1,46 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Customers; +using Grand.Web.Commands.Models.Customers; +using MediatR; + +namespace Grand.Web.Commands.Handler.Customers +{ + public class EmailCodeSendCommandHandler : IRequestHandler + { + private readonly IUserFieldService _userFieldService; + private readonly IMessageProviderService _messageProviderService; + + public EmailCodeSendCommandHandler( + IUserFieldService userFieldService, + IMessageProviderService messageProviderService) + { + _userFieldService = userFieldService; + _messageProviderService = messageProviderService; + } + + public async Task Handle(EmailCodeSendCommand request, CancellationToken cancellationToken) + { + const int MINUTES_TO_EXPIRE = 10; // This could be moved to a config area so the admin can change it. + + // Generate GUID & Timestamp + request.Customer.LoginCode = (Guid.NewGuid()).ToString(); // Store in model so we can pass it down to the message send process. + long loginCodeExpiry = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds() + (MINUTES_TO_EXPIRE * 60); + + + // Encrypt loginCode + var salt = request.Customer.PasswordSalt; + var hashedLoginCode = request.EncryptionService.CreatePasswordHash(request.Customer.LoginCode, salt, request.HashedPasswordFormat); + + // Save to Db + await _userFieldService.SaveField(request.Customer, SystemCustomerFieldNames.EmailLoginToken, hashedLoginCode); + await _userFieldService.SaveField(request.Customer, SystemCustomerFieldNames.EmailLoginTokenExpiry, loginCodeExpiry); + + // Send email + await _messageProviderService.SendCustomerEmailLoginLinkMessage(request.Customer, request.Store, request.Language.Id); + request.Customer.LoginCode = ""; // Wipe this out! Should be no reference to the unhashed version of this code on the system once we no longer need it. + + return true; + } + } +} diff --git a/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs b/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs new file mode 100644 index 000000000..5c99c6480 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs @@ -0,0 +1,22 @@ +using Grand.Business.Common.Services.Security; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Stores; +using Grand.Web.Models.Customer; +using MediatR; + + +namespace Grand.Web.Commands.Models.Customers +{ + public class EmailCodeSendCommand : IRequest + { + public LoginWithEmailCodeModel Model { get; set; } + + public Customer Customer { get; set; } + public Store Store { get; set; } + public Language Language { get; set; } + + public HashedPasswordFormat HashedPasswordFormat { get; set; } = HashedPasswordFormat.SHA1; + public EncryptionService EncryptionService { get; set; } = new EncryptionService(); + } +} diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 1995a4a50..5ff2667b2 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -275,151 +275,183 @@ public virtual async Task Logout([FromServices] StoreInformationS //available even when navigation is not allowed [PublicStore(true)] - public virtual IActionResult LoginWithEmailCode() + public virtual async Task LoginWithEmailCode(string? userId, string? loginCode) { - var model = new LoginWithEmailCodeModel(); - model.DisplayCaptcha = _captchaSettings.Enabled; - return View(model); - } + // Prepare model as it's used later in the method + var model = new LoginWithEmailCodeModel() { DisplayCaptcha = _captchaSettings.Enabled }; - [HttpPost] - [AutoValidateAntiforgeryToken] - [ValidateCaptcha] - [PublicStore(true)] - public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) - { - //validate CAPTCHA - if (_captchaSettings.Enabled && !captchaValid) + if (userId == null || loginCode == null) return View(model); // if no parameters - present login page + + // otherwise, it's a login attempt... + + var loginResult = await _customerManagerService.LoginCustomerWithEmailCode(userId, loginCode); + var customer = await _customerService.GetCustomerById(userId); + + switch (loginResult) { - ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); + case CustomerLoginResults.Successful: + { + //sign in + return await SignInAction(customer, false, "/"); // Send False for 'Remember Me' & send customer to index + } + case CustomerLoginResults.RequiresTwoFactor: + { + var userName = _customerSettings.UsernamesEnabled ? customer.Username : customer.Email; + HttpContext.Session.SetString("RequiresTwoFactor", userName); + return RedirectToRoute("TwoFactorAuthorization"); + } + + // Removed other case statements as they are highly unlikely to occur unless the user has been edited the url directly. + case CustomerLoginResults.LockedOut: + model.Result = _translationService.GetResource("Account.Login.WrongCredentials.LockedOut"); + break; + case CustomerLoginResults.WrongPassword: // It's most likely to has expired in this case. + default: + model.Result = _translationService.GetResource("Account.Login.WrongCredentials.CodeExpired"); + break; } - if (ModelState.IsValid) + //If we got this far, something failed - send to login form. + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [ValidateCaptcha] + [PublicStore(true)] + public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) { - var customer = await _customerService.GetCustomerByEmail(model.Email); - if (customer != null && customer.Active && !customer.Deleted) + //validate CAPTCHA + if (_captchaSettings.Enabled && !captchaValid) { - // TODO - Actually send the e-mail! - //await _mediator.Send(new PasswordRecoverySendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model }); - - model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); - model.Send = true; + ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); } - else + + if (ModelState.IsValid) { - model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); + var customer = await _customerService.GetCustomerByEmail(model.Email); + if (customer != null && customer.Active && !customer.Deleted) + { + await _mediator.Send(new EmailCodeSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat }); + + model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); + model.Send = true; + } + else + { + model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); + } + + return View(model); } return View(model); } - return View(model); - } - #endregion #region Password recovery - //available even when navigation is not allowed - [PublicStore(true)] - public virtual IActionResult PasswordRecovery() - { - var model = new PasswordRecoveryModel(); - model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage; - return View(model); - } - - [HttpPost] - [AutoValidateAntiforgeryToken] - [ValidateCaptcha] - [PublicStore(true)] - public virtual async Task PasswordRecovery(PasswordRecoveryModel model, bool captchaValid) - { - //validate CAPTCHA - if (_captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage && !captchaValid) + //available even when navigation is not allowed + [PublicStore(true)] + public virtual IActionResult PasswordRecovery() { - ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); + var model = new PasswordRecoveryModel(); + model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage; + return View(model); } - if (ModelState.IsValid) + [HttpPost] + [AutoValidateAntiforgeryToken] + [ValidateCaptcha] + [PublicStore(true)] + public virtual async Task PasswordRecovery(PasswordRecoveryModel model, bool captchaValid) { - var customer = await _customerService.GetCustomerByEmail(model.Email); - if (customer != null && customer.Active && !customer.Deleted) + //validate CAPTCHA + if (_captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage && !captchaValid) { - await _mediator.Send(new PasswordRecoverySendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model }); - - model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailHasBeenSent"); - model.Send = true; + ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); } - else + + if (ModelState.IsValid) { - model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailNotFound"); - } + var customer = await _customerService.GetCustomerByEmail(model.Email); + if (customer != null && customer.Active && !customer.Deleted) + { + await _mediator.Send(new PasswordRecoverySendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model }); + + model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailHasBeenSent"); + model.Send = true; + } + else + { + model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailNotFound"); + } + return View(model); + } return View(model); } - return View(model); - } - - [PublicStore(true)] - public virtual async Task PasswordRecoveryConfirm(string token, string email) - { - var customer = await _customerService.GetCustomerByEmail(email); - if (customer == null) - return RedirectToRoute("HomePage"); - - var model = await _mediator.Send(new GetPasswordRecoveryConfirm() { Customer = customer, Token = token }); - - return View(model); - } - - [HttpPost] - [AutoValidateAntiforgeryToken] - //available even when navigation is not allowed - [PublicStore(true)] - public virtual async Task PasswordRecoveryConfirm(string token, string email, PasswordRecoveryConfirmModel model) - { - var customer = await _customerService.GetCustomerByEmail(email); - if (customer == null) - return RedirectToRoute("HomePage"); - //validate token - if (!customer.IsPasswordRecoveryTokenValid(token)) + [PublicStore(true)] + public virtual async Task PasswordRecoveryConfirm(string token, string email) { - model.DisablePasswordChanging = true; - model.Result = _translationService.GetResource("Account.PasswordRecovery.WrongToken"); - } + var customer = await _customerService.GetCustomerByEmail(email); + if (customer == null) + return RedirectToRoute("HomePage"); + + var model = await _mediator.Send(new GetPasswordRecoveryConfirm() { Customer = customer, Token = token }); - //validate token expiration date - if (customer.IsPasswordRecoveryLinkExpired(_customerSettings)) - { - model.DisablePasswordChanging = true; - model.Result = _translationService.GetResource("Account.PasswordRecovery.LinkExpired"); return View(model); } - if (ModelState.IsValid) + [HttpPost] + [AutoValidateAntiforgeryToken] + //available even when navigation is not allowed + [PublicStore(true)] + public virtual async Task PasswordRecoveryConfirm(string token, string email, PasswordRecoveryConfirmModel model) { - var response = await _customerManagerService.ChangePassword(new ChangePasswordRequest(email, - false, _customerSettings.DefaultPasswordFormat, model.NewPassword)); - if (response.Success) - { - await _userFieldService.SaveField(customer, SystemCustomerFieldNames.PasswordRecoveryToken, ""); + var customer = await _customerService.GetCustomerByEmail(email); + if (customer == null) + return RedirectToRoute("HomePage"); + //validate token + if (!customer.IsPasswordRecoveryTokenValid(token)) + { model.DisablePasswordChanging = true; - model.Result = _translationService.GetResource("Account.PasswordRecovery.PasswordHasBeenChanged"); + model.Result = _translationService.GetResource("Account.PasswordRecovery.WrongToken"); } - else + + //validate token expiration date + if (customer.IsPasswordRecoveryLinkExpired(_customerSettings)) { - model.Result = response.Errors.FirstOrDefault(); + model.DisablePasswordChanging = true; + model.Result = _translationService.GetResource("Account.PasswordRecovery.LinkExpired"); + return View(model); } + if (ModelState.IsValid) + { + var response = await _customerManagerService.ChangePassword(new ChangePasswordRequest(email, + false, _customerSettings.DefaultPasswordFormat, model.NewPassword)); + if (response.Success) + { + await _userFieldService.SaveField(customer, SystemCustomerFieldNames.PasswordRecoveryToken, ""); + + model.DisablePasswordChanging = true; + model.Result = _translationService.GetResource("Account.PasswordRecovery.PasswordHasBeenChanged"); + } + else + { + model.Result = response.Errors.FirstOrDefault(); + } + + return View(model); + } return View(model); } - return View(model); - } - #endregion + #endregion #region Register diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 61829a6bb..c8c862ccf 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -118,6 +118,11 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st pattern + "LoginWithEmailCode", new { controller = "Account", action = "LoginWithEmailCode" }); + //Login with email code + endpointRouteBuilder.MapControllerRoute("LoginWithEmailCodeConfirm", + pattern + "LoginWithEmailCodeConfirm", + new { controller = "Account", action = "SecureLoginWithEmailCode" }); + //passwordrecovery endpointRouteBuilder.MapControllerRoute("PasswordRecovery", pattern + "passwordrecovery", From 67ef1650441f7c38325047272d3e4443030403b3 Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Sun, 22 May 2022 21:48:23 +0100 Subject: [PATCH 3/8] [Refactor] Fix misaligned tabs in password recovery section caused by previous commit --- .../Controllers/AccountController.cs | 200 +++++++++--------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 5ff2667b2..9d1c68845 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -315,143 +315,143 @@ public virtual async Task LoginWithEmailCode(string? userId, stri return View(model); } - [HttpPost] - [AutoValidateAntiforgeryToken] - [ValidateCaptcha] - [PublicStore(true)] - public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) + [HttpPost] + [AutoValidateAntiforgeryToken] + [ValidateCaptcha] + [PublicStore(true)] + public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) + { + //validate CAPTCHA + if (_captchaSettings.Enabled && !captchaValid) { - //validate CAPTCHA - if (_captchaSettings.Enabled && !captchaValid) - { - ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); - } + ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); + } - if (ModelState.IsValid) + if (ModelState.IsValid) + { + var customer = await _customerService.GetCustomerByEmail(model.Email); + if (customer != null && customer.Active && !customer.Deleted) { - var customer = await _customerService.GetCustomerByEmail(model.Email); - if (customer != null && customer.Active && !customer.Deleted) - { - await _mediator.Send(new EmailCodeSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat }); - - model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); - model.Send = true; - } - else - { - model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); - } + await _mediator.Send(new EmailCodeSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat }); - return View(model); + model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); + model.Send = true; + } + else + { + model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); } return View(model); } - #endregion + return View(model); + } + + #endregion + + #region Password recovery - #region Password recovery + //available even when navigation is not allowed + [PublicStore(true)] + public virtual IActionResult PasswordRecovery() + { + var model = new PasswordRecoveryModel(); + model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage; + return View(model); + } - //available even when navigation is not allowed - [PublicStore(true)] - public virtual IActionResult PasswordRecovery() + [HttpPost] + [AutoValidateAntiforgeryToken] + [ValidateCaptcha] + [PublicStore(true)] + public virtual async Task PasswordRecovery(PasswordRecoveryModel model, bool captchaValid) + { + //validate CAPTCHA + if (_captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage && !captchaValid) { - var model = new PasswordRecoveryModel(); - model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage; - return View(model); + ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); } - [HttpPost] - [AutoValidateAntiforgeryToken] - [ValidateCaptcha] - [PublicStore(true)] - public virtual async Task PasswordRecovery(PasswordRecoveryModel model, bool captchaValid) + if (ModelState.IsValid) { - //validate CAPTCHA - if (_captchaSettings.Enabled && _captchaSettings.ShowOnPasswordRecoveryPage && !captchaValid) + var customer = await _customerService.GetCustomerByEmail(model.Email); + if (customer != null && customer.Active && !customer.Deleted) { - ModelState.AddModelError("", _captchaSettings.GetWrongCaptchaMessage(_translationService)); - } + await _mediator.Send(new PasswordRecoverySendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model }); - if (ModelState.IsValid) + model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailHasBeenSent"); + model.Send = true; + } + else { - var customer = await _customerService.GetCustomerByEmail(model.Email); - if (customer != null && customer.Active && !customer.Deleted) - { - await _mediator.Send(new PasswordRecoverySendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model }); - - model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailHasBeenSent"); - model.Send = true; - } - else - { - model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailNotFound"); - } - - return View(model); + model.Result = _translationService.GetResource("Account.PasswordRecovery.EmailNotFound"); } + return View(model); } + return View(model); + } - [PublicStore(true)] - public virtual async Task PasswordRecoveryConfirm(string token, string email) - { - var customer = await _customerService.GetCustomerByEmail(email); - if (customer == null) - return RedirectToRoute("HomePage"); + [PublicStore(true)] + public virtual async Task PasswordRecoveryConfirm(string token, string email) + { + var customer = await _customerService.GetCustomerByEmail(email); + if (customer == null) + return RedirectToRoute("HomePage"); - var model = await _mediator.Send(new GetPasswordRecoveryConfirm() { Customer = customer, Token = token }); + var model = await _mediator.Send(new GetPasswordRecoveryConfirm() { Customer = customer, Token = token }); - return View(model); + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + //available even when navigation is not allowed + [PublicStore(true)] + public virtual async Task PasswordRecoveryConfirm(string token, string email, PasswordRecoveryConfirmModel model) + { + var customer = await _customerService.GetCustomerByEmail(email); + if (customer == null) + return RedirectToRoute("HomePage"); + + //validate token + if (!customer.IsPasswordRecoveryTokenValid(token)) + { + model.DisablePasswordChanging = true; + model.Result = _translationService.GetResource("Account.PasswordRecovery.WrongToken"); } - [HttpPost] - [AutoValidateAntiforgeryToken] - //available even when navigation is not allowed - [PublicStore(true)] - public virtual async Task PasswordRecoveryConfirm(string token, string email, PasswordRecoveryConfirmModel model) + //validate token expiration date + if (customer.IsPasswordRecoveryLinkExpired(_customerSettings)) { - var customer = await _customerService.GetCustomerByEmail(email); - if (customer == null) - return RedirectToRoute("HomePage"); + model.DisablePasswordChanging = true; + model.Result = _translationService.GetResource("Account.PasswordRecovery.LinkExpired"); + return View(model); + } - //validate token - if (!customer.IsPasswordRecoveryTokenValid(token)) + if (ModelState.IsValid) + { + var response = await _customerManagerService.ChangePassword(new ChangePasswordRequest(email, + false, _customerSettings.DefaultPasswordFormat, model.NewPassword)); + if (response.Success) { - model.DisablePasswordChanging = true; - model.Result = _translationService.GetResource("Account.PasswordRecovery.WrongToken"); - } + await _userFieldService.SaveField(customer, SystemCustomerFieldNames.PasswordRecoveryToken, ""); - //validate token expiration date - if (customer.IsPasswordRecoveryLinkExpired(_customerSettings)) - { model.DisablePasswordChanging = true; - model.Result = _translationService.GetResource("Account.PasswordRecovery.LinkExpired"); - return View(model); + model.Result = _translationService.GetResource("Account.PasswordRecovery.PasswordHasBeenChanged"); } - - if (ModelState.IsValid) + else { - var response = await _customerManagerService.ChangePassword(new ChangePasswordRequest(email, - false, _customerSettings.DefaultPasswordFormat, model.NewPassword)); - if (response.Success) - { - await _userFieldService.SaveField(customer, SystemCustomerFieldNames.PasswordRecoveryToken, ""); - - model.DisablePasswordChanging = true; - model.Result = _translationService.GetResource("Account.PasswordRecovery.PasswordHasBeenChanged"); - } - else - { - model.Result = response.Errors.FirstOrDefault(); - } - - return View(model); + model.Result = response.Errors.FirstOrDefault(); } + return View(model); } + return View(model); + } - #endregion + #endregion #region Register From 2acf6c6fe3ef85a10549533a0098d8f23e38c361 Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Tue, 7 Jun 2022 23:01:06 +0100 Subject: [PATCH 4/8] Changed removal of login code from database to remove code itself rather than just setting it to expire --- .../Services/CustomerManagerService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs index 577680b0c..882a82847 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs @@ -194,8 +194,8 @@ public virtual async Task LoginCustomerWithEmailCode(strin customer.LastLoginDateUtc = DateTime.UtcNow; await _customerService.UpdateCustomerLastLoginDate(customer); - // Remove code used to login so the link can't be used twice. We do this by setting the expiry timestamp to 0 - await _userFieldService.SaveField(customer, SystemCustomerFieldNames.EmailLoginTokenExpiry, 0); + // Remove code used to login so the link can't be used twice. + await _userFieldService.SaveField(customer, SystemCustomerFieldNames.EmailLoginToken, ""); return CustomerLoginResults.Successful; } From 5279733aa25576db9464b5c553cb01a67755d891 Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Sat, 11 Jun 2022 22:52:30 +0100 Subject: [PATCH 5/8] Added config settings LoginWithEmailCodeEnabled & LoginCodeMinutesToExpire --- .../Installation/InstallDataSettings.cs | 2 + .../Customers/CustomerSettings.cs | 10 +++++ .../Customer.TabCustomerSecurity.cshtml | 42 ++++++++++++++++++- .../Models/Settings/CustomerSettingsModel.cs | 6 +++ .../App_Data/Resources/DefaultLanguage.xml | 6 +++ .../Customers/EmailCodeSendCommandHandler.cs | 3 +- .../Models/Customers/EmailCodeSendCommand.cs | 2 + .../Controllers/AccountController.cs | 11 +++-- .../Grand.Web/Models/Customer/LoginModel.cs | 2 + src/Web/Grand.Web/Views/Account/Login.cshtml | 10 +++-- 10 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs b/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs index 783bb8b75..73d9e0f0a 100644 --- a/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs +++ b/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs @@ -305,6 +305,8 @@ await _settingService.SaveSetting(new CustomerSettings { AllowUsersToDeleteAccount = false, AllowUsersToExportData = false, TwoFactorAuthenticationEnabled = false, + LoginWithEmailCodeEnabled = true, + LoginCodeMinutesToExpire = 10 }); await _settingService.SaveSetting(new AddressSettings { diff --git a/src/Core/Grand.Domain/Customers/CustomerSettings.cs b/src/Core/Grand.Domain/Customers/CustomerSettings.cs index 2fc8d69ef..17efbd98f 100644 --- a/src/Core/Grand.Domain/Customers/CustomerSettings.cs +++ b/src/Core/Grand.Domain/Customers/CustomerSettings.cs @@ -194,6 +194,16 @@ public class CustomerSettings : ISettings /// public TwoFactorAuthenticationType TwoFactorAuthenticationType { get; set; } + /// + /// Defines whether the login with e-mail code functionality is enabled or disabled. + /// + public bool LoginWithEmailCodeEnabled { get; set; } + + /// + /// If the login with e-mail code is enable, how many minutes should the e-mail link stay active for before expiry. + /// + public int LoginCodeMinutesToExpire { get; set; } + /// /// Gets or sets a value indicating whether geo-location is enabled /// diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml index 81de52334..d90f76f91 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml @@ -15,6 +15,20 @@ $('#twofactortype').hide(); } } + + $(document).ready(function () { + $("#@Html.IdFor(model => model.CustomerSettings.LoginWithEmailCodeEnabled)").click(toggleLoginCodeMinutesToExpire); + toggleLoginCodeMinutesToExpire(); + }); + + function toggleLoginCodeMinutesToExpire() { + if ($('#@Html.IdFor(model => model.CustomerSettings.LoginWithEmailCodeEnabled)').is(':checked')) { + $('#minutestoexpire').show(); + } + else { + $('#minutestoexpire').hide(); + } + }
@@ -141,5 +155,29 @@
- - \ No newline at end of file + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs b/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs index c7a168375..df5a86b22 100644 --- a/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs +++ b/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs @@ -170,6 +170,12 @@ public partial class CustomersSettingsModel : BaseModel [GrandResourceDisplayName("Admin.Settings.Customer.TwoFactorAuthenticationEnabled")] public bool TwoFactorAuthenticationEnabled { get; set; } + [GrandResourceDisplayName("Admin.Settings.Customer.LoginWithEmailCodeEnabled")] + public bool LoginWithEmailCodeEnabled { get; set; } + + [GrandResourceDisplayName("Admin.Settings.Customer.LoginCodeMinutesToExpire")] + public int LoginCodeMinutesToExpire { get; set; } + [GrandResourceDisplayName("Admin.Settings.Customer.TwoFactorAuthenticationType")] public int TwoFactorAuthenticationType { get; set; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index fc76eace5..05efea78d 100644 --- a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml +++ b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml @@ -11194,6 +11194,12 @@ Two factor authentication type + + Login with email enabled + + + Minutes to expire for email login + Unduplicated passwords number diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs index d8aebeb5b..9206198ea 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs @@ -21,11 +21,10 @@ public EmailCodeSendCommandHandler( public async Task Handle(EmailCodeSendCommand request, CancellationToken cancellationToken) { - const int MINUTES_TO_EXPIRE = 10; // This could be moved to a config area so the admin can change it. // Generate GUID & Timestamp request.Customer.LoginCode = (Guid.NewGuid()).ToString(); // Store in model so we can pass it down to the message send process. - long loginCodeExpiry = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds() + (MINUTES_TO_EXPIRE * 60); + long loginCodeExpiry = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds() + (request.MinutesToExpire * 60); // Encrypt loginCode diff --git a/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs b/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs index 5c99c6480..ec362e987 100644 --- a/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs +++ b/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs @@ -16,6 +16,8 @@ public class EmailCodeSendCommand : IRequest public Store Store { get; set; } public Language Language { get; set; } + public int MinutesToExpire { get; set; } + public HashedPasswordFormat HashedPasswordFormat { get; set; } = HashedPasswordFormat.SHA1; public EncryptionService EncryptionService { get; set; } = new EncryptionService(); } diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 9d1c68845..9a1b460b0 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -88,6 +88,7 @@ public virtual IActionResult Login(bool? checkoutAsGuest) { var model = new LoginModel(); model.UsernamesEnabled = _customerSettings.UsernamesEnabled; + model.LoginWithEmailCodeEnabled = _customerSettings.LoginWithEmailCodeEnabled; model.CheckoutAsGuest = checkoutAsGuest.GetValueOrDefault(); model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage; return View(model); @@ -277,12 +278,14 @@ public virtual async Task Logout([FromServices] StoreInformationS [PublicStore(true)] public virtual async Task LoginWithEmailCode(string? userId, string? loginCode) { + if (!_customerSettings.LoginWithEmailCodeEnabled) return RedirectToAction("Login"); + // Prepare model as it's used later in the method var model = new LoginWithEmailCodeModel() { DisplayCaptcha = _captchaSettings.Enabled }; if (userId == null || loginCode == null) return View(model); // if no parameters - present login page - // otherwise, it's a login attempt... + // otherwise, it's a login attempt... var loginResult = await _customerManagerService.LoginCustomerWithEmailCode(userId, loginCode); var customer = await _customerService.GetCustomerById(userId); @@ -321,6 +324,8 @@ public virtual async Task LoginWithEmailCode(string? userId, stri [PublicStore(true)] public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) { + if (!_customerSettings.LoginWithEmailCodeEnabled) return RedirectToAction("Login"); + //validate CAPTCHA if (_captchaSettings.Enabled && !captchaValid) { @@ -332,7 +337,7 @@ public virtual async Task LoginWithEmailCode(LoginWithEmailCodeMo var customer = await _customerService.GetCustomerByEmail(model.Email); if (customer != null && customer.Active && !customer.Deleted) { - await _mediator.Send(new EmailCodeSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat }); + await _mediator.Send(new EmailCodeSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat, MinutesToExpire = _customerSettings.LoginCodeMinutesToExpire }); model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); model.Send = true; @@ -341,7 +346,7 @@ public virtual async Task LoginWithEmailCode(LoginWithEmailCodeMo { model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); } - + return View(model); } diff --git a/src/Web/Grand.Web/Models/Customer/LoginModel.cs b/src/Web/Grand.Web/Models/Customer/LoginModel.cs index 7a5abc501..a22d3a0f9 100644 --- a/src/Web/Grand.Web/Models/Customer/LoginModel.cs +++ b/src/Web/Grand.Web/Models/Customer/LoginModel.cs @@ -25,5 +25,7 @@ public partial class LoginModel : BaseModel public bool DisplayCaptcha { get; set; } + public bool LoginWithEmailCodeEnabled { get; set; } + } } \ 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 72cbb5210..adb78c20a 100644 --- a/src/Web/Grand.Web/Views/Account/Login.cshtml +++ b/src/Web/Grand.Web/Views/Account/Login.cshtml @@ -79,9 +79,13 @@ @Loc["Account.Login.ForgotPassword"] - + @if (Model.LoginWithEmailCodeEnabled) + { + + } + @if (Model.DisplayCaptcha) { From 6da24357a59eb28ebd9002d1057d647a7a90c99b Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Sat, 25 Jun 2022 22:52:48 +0100 Subject: [PATCH 6/8] Added migrations for Email Login functionality under 2.1 --- .../Grand.Business.System.csproj | 1 + ...MigrationUpdateCustomerSecuritySettings.cs | 45 ++++++++++++++++ .../MigrationUpdateDataMessageTemplates.cs | 52 +++++++++++++++++++ .../2.1/MigrationUpdateResourceString.cs | 24 +++++++++ .../2.1/MigrationUpgradeDbVersion_21.cs | 37 +++++++++++++ .../App_Data/Resources/Upgrade/en_201.xml | 36 +++++++++++++ 6 files changed, 195 insertions(+) create mode 100644 src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs create mode 100644 src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateDataMessageTemplates.cs create mode 100644 src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateResourceString.cs create mode 100644 src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpgradeDbVersion_21.cs create mode 100644 src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml diff --git a/src/Business/Grand.Business.System/Grand.Business.System.csproj b/src/Business/Grand.Business.System/Grand.Business.System.csproj index 242a2b27a..26527e54e 100644 --- a/src/Business/Grand.Business.System/Grand.Business.System.csproj +++ b/src/Business/Grand.Business.System/Grand.Business.System.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs new file mode 100644 index 000000000..d7dee8408 --- /dev/null +++ b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs @@ -0,0 +1,45 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Logging; +using Grand.Business.Core.Utilities.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Data; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace Grand.Business.System.Services.Migrations._2._1 +{ + public class MigrationUpdateCustomerSecuritySettings : IMigration + { + public int Priority => 0; + public DbVersion Version => new(2, 1); + public Guid Identity => new("4B972F99-CDEB-4521-919F-50C2376CA6FA"); + public string Name => "Sets default values for new Customer Security config settings"; + + /// + /// Upgrade process + /// + /// + /// + /// + public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService(); + + try + { + + repository.SaveSetting(new CustomerSettings { + LoginWithEmailCodeEnabled = false, + LoginCodeMinutesToExpire = 10 + }); + } + catch (Exception ex) + { + logService.InsertLog(Domain.Logging.LogLevel.Error, "UpgradeProcess - Add new Customer Security Settings", ex.Message).GetAwaiter().GetResult(); + } + return true; + } + } +} \ No newline at end of file diff --git a/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateDataMessageTemplates.cs b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateDataMessageTemplates.cs new file mode 100644 index 000000000..ce7195a21 --- /dev/null +++ b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateDataMessageTemplates.cs @@ -0,0 +1,52 @@ +using Grand.Business.Core.Interfaces.Common.Logging; +using Grand.Domain.Data; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Messages; + +namespace Grand.Business.System.Services.Migrations._2._1 +{ + public class MigrationUpdateDataMessageTemplates: IMigration + { + public int Priority => 0; + public DbVersion Version => new(2, 1); + public Guid Identity => new("AFC66A81-E728-44B0-B9E7-045E4C2D86DE"); + public string Name => "Sets new Data Message Templates"; + + /// + /// Upgrade process + /// + /// + /// + /// + public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider) + { + var messageRepository = serviceProvider.GetRequiredService(); + var emailRepository = serviceProvider.GetRequiredService(); + + var logService = serviceProvider.GetRequiredService(); + + try + { + + var eaGeneral = emailRepository.GetAllEmailAccounts().Result.FirstOrDefault(); + if (eaGeneral == null) + throw new Exception("Default email account cannot be loaded"); + + messageRepository.InsertMessageTemplate(new MessageTemplate { + Name = "Customer.EmailLoginCode", + Subject = "Login to {{Store.Name}}", + Body = "{{Store.Name}}
\r\n
\r\n To login to {{Store.Name}} click here.
\r\n
\r\n {{Store.Name}}", + IsActive = true, + EmailAccountId = eaGeneral.Id, + }); + } + catch (Exception ex) + { + logService.InsertLog(Domain.Logging.LogLevel.Error, "UpgradeProcess - Add new Data Message Template", ex.Message).GetAwaiter().GetResult(); + } + return true; + } + } +} \ No newline at end of file diff --git a/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateResourceString.cs b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateResourceString.cs new file mode 100644 index 000000000..e3e2c9a7f --- /dev/null +++ b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateResourceString.cs @@ -0,0 +1,24 @@ +using Grand.Domain.Data; +using Grand.Infrastructure.Migrations; + +namespace Grand.Business.System.Services.Migrations._2._1 +{ + public class MigrationUpdateResourceString : IMigration + { + public int Priority => 0; + public DbVersion Version => new(2, 1); + public Guid Identity => new("A095104A-b784-4DA7-8380-252A0C3C7404"); + public string Name => "Update resource string for english language 2.1"; + + /// + /// Upgrade process + /// + /// + /// + /// + public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider) + { + return serviceProvider.ImportLanguageResourcesFromXml("App_Data/Resources/Upgrade/en_201.xml"); + } + } +} \ No newline at end of file diff --git a/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpgradeDbVersion_21.cs b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpgradeDbVersion_21.cs new file mode 100644 index 000000000..1b1838fbf --- /dev/null +++ b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpgradeDbVersion_21.cs @@ -0,0 +1,37 @@ +using Grand.Domain.Common; +using Grand.Domain.Data; +using Grand.Infrastructure; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace Grand.Business.System.Services.Migrations._2._1 +{ + public class MigrationUpgradeDbVersion_21 : IMigration + { + + public int Priority => 0; + + public DbVersion Version => new(2, 1); + + public Guid Identity => new("7BA917FD-945C-4877-8732-EA09155129A8"); + + public string Name => "Upgrade version of the database to 2.1"; + + /// + /// Upgrade process + /// + /// + /// + /// + public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + + var dbversion = repository.Table.ToList().FirstOrDefault(); + dbversion.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; + repository.Update(dbversion); + + return true; + } + } +} diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml new file mode 100644 index 000000000..9e705025d --- /dev/null +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml @@ -0,0 +1,36 @@ + + + + Login with E-mail Code + + + Login With E-mail Code + + + Your email address + + + Send Email + + + Please enter your email address below. You will receive a link to login to your account without having to enter your password. + + + Login email has been sent to you. + + + Email not found. + + + Login With E-mail Code + + + The login link provided has expired or has already been used. + + + Login with email enabled + + + Minutes to expire for email login + + \ No newline at end of file From daf3c170c43f4cbebf9ce4f7be5064fd1008eaff Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Sat, 25 Jun 2022 23:26:27 +0100 Subject: [PATCH 7/8] Renamed login with "Login Code" to login with "Magic Link", a term more used in indsutry to refer to this functionality. --- .../Customers/ICustomerManagerService.cs | 2 +- .../Messages/DotLiquidDrops/LiquidCustomer.cs | 2 +- .../Services/CustomerManagerService.cs | 2 +- .../Installation/InstallDataRobotsTxt.cs | 2 +- .../Installation/InstallDataSettings.cs | 2 +- ...MigrationUpdateCustomerSecuritySettings.cs | 2 +- .../Customers/CustomerSettings.cs | 2 +- .../Customer.TabCustomerSecurity.cshtml | 10 +++---- .../Models/Settings/CustomerSettingsModel.cs | 4 +-- .../App_Data/Resources/DefaultLanguage.xml | 28 +++++++++---------- .../App_Data/Resources/Upgrade/en_201.xml | 28 +++++++++---------- ...dler.cs => MagicLinkSendCommandHandler.cs} | 6 ++-- ...SendCommand.cs => MagicLinkSendCommand.cs} | 4 +-- .../Controllers/AccountController.cs | 20 ++++++------- .../Grand.Web/Endpoints/EndpointProvider.cs | 12 ++++---- .../Grand.Web/Models/Customer/LoginModel.cs | 2 +- ...odeModel.cs => LoginWithMagicLinkModel.cs} | 4 +-- src/Web/Grand.Web/Views/Account/Login.cshtml | 4 +-- ...lCode.cshtml => LoginWithMagicLink.cshtml} | 14 +++++----- 19 files changed, 75 insertions(+), 75 deletions(-) rename src/Web/Grand.Web/Commands/Handler/Customers/{EmailCodeSendCommandHandler.cs => MagicLinkSendCommandHandler.cs} (90%) rename src/Web/Grand.Web/Commands/Models/Customers/{EmailCodeSendCommand.cs => MagicLinkSendCommand.cs} (84%) rename src/Web/Grand.Web/Models/Customer/{LoginWithEmailCodeModel.cs => LoginWithMagicLinkModel.cs} (76%) rename src/Web/Grand.Web/Views/Account/{LoginWithEmailCode.cshtml => LoginWithMagicLink.cshtml} (82%) diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs index cb9d72bab..2c419464c 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs @@ -22,7 +22,7 @@ public partial interface ICustomerManagerService /// UserId of the record /// loginCode provided in e-mail /// Result - Task LoginCustomerWithEmailCode(string userId, string loginCode); + Task LoginCustomerWithMagicLink(string userId, string loginCode); /// /// Register customer diff --git a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs index 8d054b59d..c0ad4cfc7 100644 --- a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs @@ -112,7 +112,7 @@ public string PasswordRecoveryURL public string LoginCodeURL { - get { return string.Format("{0}/LoginWithEmailCode/?userId={1}&loginCode={2}", url, _customer.Id, _customer.LoginCode); } + get { return string.Format("{0}/LoginWithMagicLink/?userId={1}&loginCode={2}", url, _customer.Id, _customer.LoginCode); } } public string AccountActivationURL diff --git a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs index 882a82847..2e2548c2a 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs @@ -145,7 +145,7 @@ public virtual async Task LoginCustomer(string usernameOrE /// UserId of the record /// loginCode provided in e-mail /// Result - public virtual async Task LoginCustomerWithEmailCode(string userId, string loginCode) + public virtual async Task LoginCustomerWithMagicLink(string userId, string loginCode) { var customer = await _customerService.GetCustomerById(userId); diff --git a/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs b/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs index 373cd2082..ddaecb352 100644 --- a/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs +++ b/src/Business/Grand.Business.System/Services/Installation/InstallDataRobotsTxt.cs @@ -42,7 +42,7 @@ protected virtual async Task InstallDataRobotsTxt( Disallow: /order/* Disallow: /orderdetails Disallow: /passwordrecovery/confirm -Disallow: /LoginWithEmailCode +Disallow: /LoginWithMagicLink Disallow: /popupinteractiveform Disallow: /register/* Disallow: /merchandisereturn diff --git a/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs b/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs index 73d9e0f0a..67475add9 100644 --- a/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs +++ b/src/Business/Grand.Business.System/Services/Installation/InstallDataSettings.cs @@ -305,7 +305,7 @@ await _settingService.SaveSetting(new CustomerSettings { AllowUsersToDeleteAccount = false, AllowUsersToExportData = false, TwoFactorAuthenticationEnabled = false, - LoginWithEmailCodeEnabled = true, + LoginWithMagicLinkEnabled = true, LoginCodeMinutesToExpire = 10 }); diff --git a/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs index d7dee8408..d7d0803d3 100644 --- a/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs +++ b/src/Business/Grand.Business.System/Services/Migrations/2.1/MigrationUpdateCustomerSecuritySettings.cs @@ -31,7 +31,7 @@ public bool UpgradeProcess(IDatabaseContext database, IServiceProvider servicePr { repository.SaveSetting(new CustomerSettings { - LoginWithEmailCodeEnabled = false, + LoginWithMagicLinkEnabled = false, LoginCodeMinutesToExpire = 10 }); } diff --git a/src/Core/Grand.Domain/Customers/CustomerSettings.cs b/src/Core/Grand.Domain/Customers/CustomerSettings.cs index 17efbd98f..80de8d96a 100644 --- a/src/Core/Grand.Domain/Customers/CustomerSettings.cs +++ b/src/Core/Grand.Domain/Customers/CustomerSettings.cs @@ -197,7 +197,7 @@ public class CustomerSettings : ISettings /// /// Defines whether the login with e-mail code functionality is enabled or disabled. /// - public bool LoginWithEmailCodeEnabled { get; set; } + public bool LoginWithMagicLinkEnabled { get; set; } /// /// If the login with e-mail code is enable, how many minutes should the e-mail link stay active for before expiry. diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml index d90f76f91..58ce786e7 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml @@ -17,12 +17,12 @@ } $(document).ready(function () { - $("#@Html.IdFor(model => model.CustomerSettings.LoginWithEmailCodeEnabled)").click(toggleLoginCodeMinutesToExpire); + $("#@Html.IdFor(model => model.CustomerSettings.LoginWithMagicLinkEnabled)").click(toggleLoginCodeMinutesToExpire); toggleLoginCodeMinutesToExpire(); }); function toggleLoginCodeMinutesToExpire() { - if ($('#@Html.IdFor(model => model.CustomerSettings.LoginWithEmailCodeEnabled)').is(':checked')) { + if ($('#@Html.IdFor(model => model.CustomerSettings.LoginWithMagicLinkEnabled)').is(':checked')) { $('#minutestoexpire').show(); } else { @@ -158,14 +158,14 @@
- +
- +
diff --git a/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs b/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs index df5a86b22..1534e02f6 100644 --- a/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs +++ b/src/Web/Grand.Web.Admin/Models/Settings/CustomerSettingsModel.cs @@ -170,8 +170,8 @@ public partial class CustomersSettingsModel : BaseModel [GrandResourceDisplayName("Admin.Settings.Customer.TwoFactorAuthenticationEnabled")] public bool TwoFactorAuthenticationEnabled { get; set; } - [GrandResourceDisplayName("Admin.Settings.Customer.LoginWithEmailCodeEnabled")] - public bool LoginWithEmailCodeEnabled { get; set; } + [GrandResourceDisplayName("Admin.Settings.Customer.LoginWithMagicLinkEnabled")] + public bool LoginWithMagicLinkEnabled { get; set; } [GrandResourceDisplayName("Admin.Settings.Customer.LoginCodeMinutesToExpire")] public int LoginCodeMinutesToExpire { get; set; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 05efea78d..487642bb2 100644 --- a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml +++ b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml @@ -516,8 +516,8 @@ Forgot password? - - Login with E-mail Code + + Login with Magic Link Log in @@ -588,22 +588,22 @@ Product - - Login With E-mail Code + + Login With Magic Link - + Your email address - + Send Email - + Please enter your email address below. You will receive a link to login to your account without having to enter your password. - + Login email has been sent to you. - + Email not found. @@ -11194,11 +11194,11 @@ Two factor authentication type - - Login with email enabled + + Login with magic link enabled - Minutes to expire for email login + Minutes to expire for magic link Unduplicated passwords number @@ -16563,8 +16563,8 @@ Password Recovery - - Login With E-mail Code + + Login With Magic Link Ask question diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml index 9e705025d..47d7ce77e 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml @@ -1,36 +1,36 @@ - - Login with E-mail Code + + Login with Magic Link - - Login With E-mail Code + + Login With Magic Link - + Your email address - + Send Email - + Please enter your email address below. You will receive a link to login to your account without having to enter your password. - + Login email has been sent to you. - + Email not found. - - Login With E-mail Code + + Login With Magic Link The login link provided has expired or has already been used. - - Login with email enabled + + Login with magic link enabled - Minutes to expire for email login + Minutes to expire for magic link \ No newline at end of file diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs similarity index 90% rename from src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs rename to src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs index 9206198ea..94f850a39 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/EmailCodeSendCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs @@ -6,12 +6,12 @@ namespace Grand.Web.Commands.Handler.Customers { - public class EmailCodeSendCommandHandler : IRequestHandler + public class MagicLinkSendCommandHandler : IRequestHandler { private readonly IUserFieldService _userFieldService; private readonly IMessageProviderService _messageProviderService; - public EmailCodeSendCommandHandler( + public MagicLinkSendCommandHandler( IUserFieldService userFieldService, IMessageProviderService messageProviderService) { @@ -19,7 +19,7 @@ public EmailCodeSendCommandHandler( _messageProviderService = messageProviderService; } - public async Task Handle(EmailCodeSendCommand request, CancellationToken cancellationToken) + public async Task Handle(MagicLinkSendCommand request, CancellationToken cancellationToken) { // Generate GUID & Timestamp diff --git a/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs b/src/Web/Grand.Web/Commands/Models/Customers/MagicLinkSendCommand.cs similarity index 84% rename from src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs rename to src/Web/Grand.Web/Commands/Models/Customers/MagicLinkSendCommand.cs index ec362e987..5682054c4 100644 --- a/src/Web/Grand.Web/Commands/Models/Customers/EmailCodeSendCommand.cs +++ b/src/Web/Grand.Web/Commands/Models/Customers/MagicLinkSendCommand.cs @@ -8,9 +8,9 @@ namespace Grand.Web.Commands.Models.Customers { - public class EmailCodeSendCommand : IRequest + public class MagicLinkSendCommand : IRequest { - public LoginWithEmailCodeModel Model { get; set; } + public LoginWithMagicLinkModel Model { get; set; } public Customer Customer { get; set; } public Store Store { get; set; } diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 9a1b460b0..37578b19f 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -88,7 +88,7 @@ public virtual IActionResult Login(bool? checkoutAsGuest) { var model = new LoginModel(); model.UsernamesEnabled = _customerSettings.UsernamesEnabled; - model.LoginWithEmailCodeEnabled = _customerSettings.LoginWithEmailCodeEnabled; + model.LoginWithMagicLinkEnabled = _customerSettings.LoginWithMagicLinkEnabled; model.CheckoutAsGuest = checkoutAsGuest.GetValueOrDefault(); model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage; return View(model); @@ -276,18 +276,18 @@ public virtual async Task Logout([FromServices] StoreInformationS //available even when navigation is not allowed [PublicStore(true)] - public virtual async Task LoginWithEmailCode(string? userId, string? loginCode) + public virtual async Task LoginWithMagicLink(string? userId, string? loginCode) { - if (!_customerSettings.LoginWithEmailCodeEnabled) return RedirectToAction("Login"); + if (!_customerSettings.LoginWithMagicLinkEnabled) return RedirectToAction("Login"); // Prepare model as it's used later in the method - var model = new LoginWithEmailCodeModel() { DisplayCaptcha = _captchaSettings.Enabled }; + var model = new LoginWithMagicLinkModel() { DisplayCaptcha = _captchaSettings.Enabled }; if (userId == null || loginCode == null) return View(model); // if no parameters - present login page // otherwise, it's a login attempt... - var loginResult = await _customerManagerService.LoginCustomerWithEmailCode(userId, loginCode); + var loginResult = await _customerManagerService.LoginCustomerWithMagicLink(userId, loginCode); var customer = await _customerService.GetCustomerById(userId); switch (loginResult) @@ -322,9 +322,9 @@ public virtual async Task LoginWithEmailCode(string? userId, stri [AutoValidateAntiforgeryToken] [ValidateCaptcha] [PublicStore(true)] - public virtual async Task LoginWithEmailCode(LoginWithEmailCodeModel model, bool captchaValid) + public virtual async Task LoginWithMagicLink(LoginWithMagicLinkModel model, bool captchaValid) { - if (!_customerSettings.LoginWithEmailCodeEnabled) return RedirectToAction("Login"); + if (!_customerSettings.LoginWithMagicLinkEnabled) return RedirectToAction("Login"); //validate CAPTCHA if (_captchaSettings.Enabled && !captchaValid) @@ -337,14 +337,14 @@ public virtual async Task LoginWithEmailCode(LoginWithEmailCodeMo var customer = await _customerService.GetCustomerByEmail(model.Email); if (customer != null && customer.Active && !customer.Deleted) { - await _mediator.Send(new EmailCodeSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat, MinutesToExpire = _customerSettings.LoginCodeMinutesToExpire }); + await _mediator.Send(new MagicLinkSendCommand() { Customer = customer, Store = _workContext.CurrentStore, Language = _workContext.WorkingLanguage, Model = model, HashedPasswordFormat = _customerSettings.HashedPasswordFormat, MinutesToExpire = _customerSettings.LoginCodeMinutesToExpire }); - model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailHasBeenSent"); + model.Result = _translationService.GetResource("Account.LoginWithMagicLink.EmailHasBeenSent"); model.Send = true; } else { - model.Result = _translationService.GetResource("Account.LoginWithEmailCode.EmailNotFound"); + model.Result = _translationService.GetResource("Account.LoginWithMagicLink.EmailNotFound"); } return View(model); diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index c8c862ccf..266ef37b6 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -114,14 +114,14 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st new { controller = "Account", action = "CheckUsernameAvailability" }); //Login with email code - endpointRouteBuilder.MapControllerRoute("LoginWithEmailCode", - pattern + "LoginWithEmailCode", - new { controller = "Account", action = "LoginWithEmailCode" }); + endpointRouteBuilder.MapControllerRoute("LoginWithMagicLink", + pattern + "LoginWithMagicLink", + new { controller = "Account", action = "LoginWithMagicLink" }); //Login with email code - endpointRouteBuilder.MapControllerRoute("LoginWithEmailCodeConfirm", - pattern + "LoginWithEmailCodeConfirm", - new { controller = "Account", action = "SecureLoginWithEmailCode" }); + endpointRouteBuilder.MapControllerRoute("LoginWithMagicLinkConfirm", + pattern + "LoginWithMagicLinkConfirm", + new { controller = "Account", action = "SecureLoginWithMagicLink" }); //passwordrecovery endpointRouteBuilder.MapControllerRoute("PasswordRecovery", diff --git a/src/Web/Grand.Web/Models/Customer/LoginModel.cs b/src/Web/Grand.Web/Models/Customer/LoginModel.cs index a22d3a0f9..7614bb772 100644 --- a/src/Web/Grand.Web/Models/Customer/LoginModel.cs +++ b/src/Web/Grand.Web/Models/Customer/LoginModel.cs @@ -25,7 +25,7 @@ public partial class LoginModel : BaseModel public bool DisplayCaptcha { get; set; } - public bool LoginWithEmailCodeEnabled { get; set; } + public bool LoginWithMagicLinkEnabled { get; set; } } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs b/src/Web/Grand.Web/Models/Customer/LoginWithMagicLinkModel.cs similarity index 76% rename from src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs rename to src/Web/Grand.Web/Models/Customer/LoginWithMagicLinkModel.cs index bfa1dd347..a997ec6c4 100644 --- a/src/Web/Grand.Web/Models/Customer/LoginWithEmailCodeModel.cs +++ b/src/Web/Grand.Web/Models/Customer/LoginWithMagicLinkModel.cs @@ -4,10 +4,10 @@ namespace Grand.Web.Models.Customer { - public partial class LoginWithEmailCodeModel : BaseModel + public partial class LoginWithMagicLinkModel : BaseModel { [DataType(DataType.EmailAddress)] - [GrandResourceDisplayName("Account.LoginWithEmailCode.Email")] + [GrandResourceDisplayName("Account.LoginWithMagicLink.Email")] public string Email { get; set; } public string Result { get; set; } public bool Send { get; set; } diff --git a/src/Web/Grand.Web/Views/Account/Login.cshtml b/src/Web/Grand.Web/Views/Account/Login.cshtml index adb78c20a..58bb14a37 100644 --- a/src/Web/Grand.Web/Views/Account/Login.cshtml +++ b/src/Web/Grand.Web/Views/Account/Login.cshtml @@ -79,10 +79,10 @@ @Loc["Account.Login.ForgotPassword"] - @if (Model.LoginWithEmailCodeEnabled) + @if (Model.LoginWithMagicLinkEnabled) { } diff --git a/src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml b/src/Web/Grand.Web/Views/Account/LoginWithMagicLink.cshtml similarity index 82% rename from src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml rename to src/Web/Grand.Web/Views/Account/LoginWithMagicLink.cshtml index 538e8d494..c37823ed6 100644 --- a/src/Web/Grand.Web/Views/Account/LoginWithEmailCode.cshtml +++ b/src/Web/Grand.Web/Views/Account/LoginWithMagicLink.cshtml @@ -1,14 +1,14 @@ -@model LoginWithEmailCodeModel +@model LoginWithMagicLinkModel @using Grand.Web.Models.Customer; @inject IPageHeadBuilder pagebuilder @{ Layout = "_SingleColumn"; //title - pagebuilder.AddTitleParts(Loc["Title.LoginWithEmailCode"]); + pagebuilder.AddTitleParts(Loc["Title.LoginWithMagicLink"]); }
-

@Loc["Account.LoginWithEmailCode"]

+

@Loc["Account.LoginWithMagicLink"]

@if (!String.IsNullOrEmpty(Model.Result)) {
@@ -18,12 +18,12 @@ @if (!Model.Send) { -
+
- + {{ errors[0] }} @@ -39,12 +39,12 @@ }
- +
- @Loc["Account.LoginWithEmailCode.Tooltip"] + @Loc["Account.LoginWithMagicLink.Tooltip"]
From efca10b35bea9f7062e25ac0c96e91c225e030b2 Mon Sep 17 00:00:00 2001 From: Lee <105460972+LeeDalchow@users.noreply.github.com> Date: Sun, 26 Jun 2022 23:04:41 +0100 Subject: [PATCH 8/8] Removed loginCode from Customer entity --- .../Interfaces/Messages/IMessageProviderService.cs | 2 +- .../Messages/DotLiquidDrops/LiquidCustomer.cs | 5 +++-- .../Messages/DotLiquidDrops/LiquidObjectBuilder.cs | 4 ++-- .../Services/MessageProviderService.cs | 13 +++++++++---- src/Core/Grand.Domain/Customers/Customer.cs | 5 ----- .../Customers/MagicLinkSendCommandHandler.cs | 7 +++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs b/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs index 4f47c8565..69f1f11ac 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Messages/IMessageProviderService.cs @@ -59,7 +59,7 @@ public partial interface IMessageProviderService /// Store /// Message language identifier /// Queued email identifier - Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId); + Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId, string loginCode); /// /// Sends a new customer note added notification to a customer diff --git a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs index c0ad4cfc7..1dd07ca9d 100644 --- a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidCustomer.cs @@ -15,7 +15,7 @@ public partial class LiquidCustomer : Drop private readonly Store _store; private readonly DomainHost _host; private readonly string url; - public LiquidCustomer(Customer customer, Store store, DomainHost host, CustomerNote customerNote = null) + public LiquidCustomer(Customer customer, Store store, DomainHost host, CustomerNote customerNote = null, string? loginCode = null) { _customer = customer; _customerNote = customerNote; @@ -23,6 +23,7 @@ public LiquidCustomer(Customer customer, Store store, DomainHost host, CustomerN _host = host; url = _host?.Url.Trim('/') ?? (_store.SslEnabled ? _store.SecureUrl.Trim('/') : _store.Url.Trim('/')); AdditionalTokens = new Dictionary(); + AdditionalTokens.Add("loginCode", loginCode); } public string Email @@ -112,7 +113,7 @@ public string PasswordRecoveryURL public string LoginCodeURL { - get { return string.Format("{0}/LoginWithMagicLink/?userId={1}&loginCode={2}", url, _customer.Id, _customer.LoginCode); } + get { return string.Format("{0}/LoginWithMagicLink/?userId={1}&loginCode={2}", url, _customer.Id, AdditionalTokens["loginCode"]); } } public string AccountActivationURL diff --git a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs index bae78b5e3..ac011a42e 100644 --- a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs +++ b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs @@ -99,11 +99,11 @@ public LiquidObjectBuilder AddGiftVoucherTokens(GiftVoucher giftVoucher, Languag return this; } - public LiquidObjectBuilder AddCustomerTokens(Customer customer, Store store, DomainHost host, Language language, CustomerNote customerNote = null) + public LiquidObjectBuilder AddCustomerTokens(Customer customer, Store store, DomainHost host, Language language, CustomerNote customerNote = null, string? loginCode = null) { _chain.Add(async liquidObject => { - var liquidCustomer = new LiquidCustomer(customer, store, host, customerNote); + var liquidCustomer = new LiquidCustomer(customer, store, host, customerNote, loginCode); liquidObject.Customer = liquidCustomer; await _mediator.EntityTokensAdded(customer, liquidCustomer, liquidObject); diff --git a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs index 4b85e53a7..1a5053951 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs @@ -141,9 +141,12 @@ protected virtual async Task EnsureLanguageIsActive(string languageId, /// Message template name /// Send email to email account /// Customer note + /// (Optional) Login Code for inclusion within magic link email /// Queued email identifier - protected virtual async Task SendCustomerMessage(Customer customer, Store store, string languageId, string templateName, bool toEmailAccount = false, CustomerNote customerNote = null) + protected virtual async Task SendCustomerMessage(Customer customer, Store store, string languageId, string templateName, bool toEmailAccount = false, CustomerNote customerNote = null, string? loginCode = null) { + // Note: If more attributes outside of the models are sent down the call stack in addition to login code in future, it may be useful to send in a hashmap called "AdditionalTokens" + if (customer == null) throw new ArgumentNullException(nameof(customer)); @@ -158,7 +161,8 @@ protected virtual async Task SendCustomerMessage(Customer customer, Store s var builder = new LiquidObjectBuilder(_mediator); builder.AddStoreTokens(store, language, emailAccount) - .AddCustomerTokens(customer, store, _storeHelper.DomainHost, language, customerNote); + .AddCustomerTokens(customer, store, _storeHelper.DomainHost, language, customerNote, loginCode); + LiquidObject liquidObject = await builder.BuildAsync(); //event notification @@ -225,10 +229,11 @@ public virtual async Task SendCustomerPasswordRecoveryMessage(Customer cust /// Customer /// Store /// Message language identifier + /// Login Code for inclusion within the URL /// Queued email identifier - public virtual async Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId) + public virtual async Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId, string loginCode) { - return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerEmailLoginCode); + return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerEmailLoginCode, false, null, loginCode); } diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index 1e9c34367..f04b1139e 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -51,11 +51,6 @@ public Customer() /// public string PasswordSalt { get; set; } - /// - /// Gets or sets the login token - /// - public string LoginCode { get; set; } - /// /// Gets or sets the admin comment /// diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs index 94f850a39..112d30e2e 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs @@ -23,21 +23,20 @@ public async Task Handle(MagicLinkSendCommand request, CancellationToken c { // Generate GUID & Timestamp - request.Customer.LoginCode = (Guid.NewGuid()).ToString(); // Store in model so we can pass it down to the message send process. + var loginCode = (Guid.NewGuid()).ToString(); long loginCodeExpiry = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds() + (request.MinutesToExpire * 60); // Encrypt loginCode var salt = request.Customer.PasswordSalt; - var hashedLoginCode = request.EncryptionService.CreatePasswordHash(request.Customer.LoginCode, salt, request.HashedPasswordFormat); + var hashedLoginCode = request.EncryptionService.CreatePasswordHash(loginCode, salt, request.HashedPasswordFormat); // Save to Db await _userFieldService.SaveField(request.Customer, SystemCustomerFieldNames.EmailLoginToken, hashedLoginCode); await _userFieldService.SaveField(request.Customer, SystemCustomerFieldNames.EmailLoginTokenExpiry, loginCodeExpiry); // Send email - await _messageProviderService.SendCustomerEmailLoginLinkMessage(request.Customer, request.Store, request.Language.Id); - request.Customer.LoginCode = ""; // Wipe this out! Should be no reference to the unhashed version of this code on the system once we no longer need it. + await _messageProviderService.SendCustomerEmailLoginLinkMessage(request.Customer, request.Store, request.Language.Id, loginCode); return true; }