diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs index 17917a8a5..2c419464c 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 LoginCustomerWithMagicLink(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..69f1f11ac 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, 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 2ca97b3a9..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 @@ -110,6 +111,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}/LoginWithMagicLink/?userId={1}&loginCode={2}", url, _customer.Id, AdditionalTokens["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.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.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs index 204adf6a5..2e2548c2a 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 LoginCustomerWithMagicLink(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. + await _userFieldService.SaveField(customer, SystemCustomerFieldNames.EmailLoginToken, ""); + + 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..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 @@ -219,6 +223,20 @@ 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 + /// Login Code for inclusion within the URL + /// Queued email identifier + public virtual async Task SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId, string loginCode) + { + return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerEmailLoginCode, false, null, loginCode); + } + + /// /// 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/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/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..ddaecb352 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: /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 783bb8b75..67475add9 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, + LoginWithMagicLinkEnabled = true, + LoginCodeMinutesToExpire = 10 }); await _settingService.SaveSetting(new AddressSettings { 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..d7d0803d3 --- /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 { + LoginWithMagicLinkEnabled = 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/Core/Grand.Domain/Customers/CustomerSettings.cs b/src/Core/Grand.Domain/Customers/CustomerSettings.cs index 2fc8d69ef..80de8d96a 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 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. + /// + public int LoginCodeMinutesToExpire { get; set; } + /// /// Gets or sets a value indicating whether geo-location is enabled /// 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.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.TabCustomerSecurity.cshtml index 81de52334..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 @@ -15,6 +15,20 @@ $('#twofactortype').hide(); } } + + $(document).ready(function () { + $("#@Html.IdFor(model => model.CustomerSettings.LoginWithMagicLinkEnabled)").click(toggleLoginCodeMinutesToExpire); + toggleLoginCodeMinutesToExpire(); + }); + + function toggleLoginCodeMinutesToExpire() { + if ($('#@Html.IdFor(model => model.CustomerSettings.LoginWithMagicLinkEnabled)').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..1534e02f6 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.LoginWithMagicLinkEnabled")] + public bool LoginWithMagicLinkEnabled { 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 78d505aac..487642bb2 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 Magic Link + Log in @@ -537,6 +540,9 @@ The credentials provided are incorrect + + The login link provided has expired or has already been used. + No customer account found @@ -582,6 +588,24 @@ Product + + 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. + Password recovery @@ -11170,6 +11194,12 @@ Two factor authentication type + + Login with magic link enabled + + + Minutes to expire for magic link + Unduplicated passwords number @@ -16533,6 +16563,9 @@ Password Recovery + + 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 new file mode 100644 index 000000000..47d7ce77e --- /dev/null +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_201.xml @@ -0,0 +1,36 @@ + + + + Login with Magic Link + + + 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 Magic Link + + + The login link provided has expired or has already been used. + + + Login with magic link enabled + + + Minutes to expire for magic link + + \ No newline at end of file diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs new file mode 100644 index 000000000..112d30e2e --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Customers/MagicLinkSendCommandHandler.cs @@ -0,0 +1,44 @@ +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 MagicLinkSendCommandHandler : IRequestHandler + { + private readonly IUserFieldService _userFieldService; + private readonly IMessageProviderService _messageProviderService; + + public MagicLinkSendCommandHandler( + IUserFieldService userFieldService, + IMessageProviderService messageProviderService) + { + _userFieldService = userFieldService; + _messageProviderService = messageProviderService; + } + + public async Task Handle(MagicLinkSendCommand request, CancellationToken cancellationToken) + { + + // Generate GUID & Timestamp + 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(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, loginCode); + + return true; + } + } +} diff --git a/src/Web/Grand.Web/Commands/Models/Customers/MagicLinkSendCommand.cs b/src/Web/Grand.Web/Commands/Models/Customers/MagicLinkSendCommand.cs new file mode 100644 index 000000000..5682054c4 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Models/Customers/MagicLinkSendCommand.cs @@ -0,0 +1,24 @@ +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 MagicLinkSendCommand : IRequest + { + public LoginWithMagicLinkModel Model { get; set; } + + public Customer Customer { get; set; } + 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 b167d4737..37578b19f 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.LoginWithMagicLinkEnabled = _customerSettings.LoginWithMagicLinkEnabled; model.CheckoutAsGuest = checkoutAsGuest.GetValueOrDefault(); model.DisplayCaptcha = _captchaSettings.Enabled && _captchaSettings.ShowOnLoginPage; return View(model); @@ -271,10 +272,93 @@ 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 async Task LoginWithMagicLink(string? userId, string? loginCode) + { + if (!_customerSettings.LoginWithMagicLinkEnabled) return RedirectToAction("Login"); + + // Prepare model as it's used later in the method + 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.LoginCustomerWithMagicLink(userId, loginCode); + var customer = await _customerService.GetCustomerById(userId); + + switch (loginResult) + { + 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 we got this far, something failed - send to login form. + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [ValidateCaptcha] + [PublicStore(true)] + public virtual async Task LoginWithMagicLink(LoginWithMagicLinkModel model, bool captchaValid) + { + if (!_customerSettings.LoginWithMagicLinkEnabled) return RedirectToAction("Login"); + + //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) + { + 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.LoginWithMagicLink.EmailHasBeenSent"); + model.Send = true; + } + else + { + model.Result = _translationService.GetResource("Account.LoginWithMagicLink.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..266ef37b6 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -113,6 +113,16 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st pattern + "account/checkusernameavailability", new { controller = "Account", action = "CheckUsernameAvailability" }); + //Login with email code + endpointRouteBuilder.MapControllerRoute("LoginWithMagicLink", + pattern + "LoginWithMagicLink", + new { controller = "Account", action = "LoginWithMagicLink" }); + + //Login with email code + endpointRouteBuilder.MapControllerRoute("LoginWithMagicLinkConfirm", + pattern + "LoginWithMagicLinkConfirm", + new { controller = "Account", action = "SecureLoginWithMagicLink" }); + //passwordrecovery endpointRouteBuilder.MapControllerRoute("PasswordRecovery", pattern + "passwordrecovery", diff --git a/src/Web/Grand.Web/Models/Customer/LoginModel.cs b/src/Web/Grand.Web/Models/Customer/LoginModel.cs index 7a5abc501..7614bb772 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 LoginWithMagicLinkEnabled { get; set; } + } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Models/Customer/LoginWithMagicLinkModel.cs b/src/Web/Grand.Web/Models/Customer/LoginWithMagicLinkModel.cs new file mode 100644 index 000000000..a997ec6c4 --- /dev/null +++ b/src/Web/Grand.Web/Models/Customer/LoginWithMagicLinkModel.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 LoginWithMagicLinkModel : BaseModel + { + [DataType(DataType.EmailAddress)] + [GrandResourceDisplayName("Account.LoginWithMagicLink.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..58bb14a37 100644 --- a/src/Web/Grand.Web/Views/Account/Login.cshtml +++ b/src/Web/Grand.Web/Views/Account/Login.cshtml @@ -79,6 +79,13 @@ @Loc["Account.Login.ForgotPassword"] + @if (Model.LoginWithMagicLinkEnabled) + { + + @Loc["Account.Login.MagicLink"] + + } + @if (Model.DisplayCaptcha) { diff --git a/src/Web/Grand.Web/Views/Account/LoginWithMagicLink.cshtml b/src/Web/Grand.Web/Views/Account/LoginWithMagicLink.cshtml new file mode 100644 index 000000000..c37823ed6 --- /dev/null +++ b/src/Web/Grand.Web/Views/Account/LoginWithMagicLink.cshtml @@ -0,0 +1,59 @@ +@model LoginWithMagicLinkModel +@using Grand.Web.Models.Customer; +@inject IPageHeadBuilder pagebuilder +@{ + Layout = "_SingleColumn"; + + //title + pagebuilder.AddTitleParts(Loc["Title.LoginWithMagicLink"]); +} + + @Loc["Account.LoginWithMagicLink"] + @if (!String.IsNullOrEmpty(Model.Result)) + { + + @Model.Result + + } + @if (!Model.Send) + { + + + + + + + @Loc["Account.LoginWithMagicLink.Email"]: + + {{ errors[0] }} + + + + @if (Model.DisplayCaptcha) + { + + + + + + } + + + @Loc["Account.LoginWithMagicLink.SendButton"] + + + + + @Loc["Account.LoginWithMagicLink.Tooltip"] + + + + } + + \ No newline at end of file