Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Login with email code #270

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ public partial interface ICustomerManagerService
/// <returns>Result</returns>
Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password);

/// <summary>
/// Login customer with E-mail Code
/// </summary>
/// <param name="userId">UserId of the record</param>
/// <param name="loginCode">loginCode provided in e-mail</param>
/// <returns>Result</returns>
Task<CustomerLoginResults> LoginCustomerWithMagicLink(string userId, string loginCode);

/// <summary>
/// Register customer
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ public partial interface IMessageProviderService
/// <returns>Queued email identifier</returns>
Task<int> SendCustomerPasswordRecoveryMessage(Customer customer, Store store, string languageId);

/// <summary>
/// Sends E-mail login code to the customer
/// </summary>
/// <param name="customer">Customer instance</param>
/// <param name="store">Store</param>
/// <param name="languageId">Message language identifier</param>
/// <returns>Queued email identifier</returns>
Task<int> SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId, string loginCode);

/// <summary>
/// Sends a new customer note added notification to a customer
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ 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;
_store = store;
_host = host;
url = _host?.Url.Trim('/') ?? (_store.SslEnabled ? _store.SecureUrl.Trim('/') : _store.Url.Trim('/'));
AdditionalTokens = new Dictionary<string, string>();
AdditionalTokens.Add("loginCode", loginCode);
}

public string Email
Expand Down Expand Up @@ -110,6 +111,11 @@ public string PasswordRecoveryURL
get { return string.Format("{0}/passwordrecovery/confirm?token={1}&email={2}", url, _customer.GetUserFieldFromEntity<string>(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<string>(SystemCustomerFieldNames.AccountActivationToken), WebUtility.UrlEncode(_customer.Email)); ; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,67 @@ public virtual async Task<CustomerLoginResults> LoginCustomer(string usernameOrE
return CustomerLoginResults.Successful;
}

/// <summary>
/// Login customer with E-mail Code
/// </summary>
/// <param name="userId">UserId of the record</param>
/// <param name="loginCode">loginCode provided in e-mail</param>
/// <returns>Result</returns>
public virtual async Task<CustomerLoginResults> 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<string>(customer, SystemCustomerFieldNames.EmailLoginToken);
long savedHashedLoginCodeExpiry = await _userFieldService.GetFieldsForEntity<long>(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<bool>(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;
}

/// <summary>
/// Register customer
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,12 @@ protected virtual async Task<Language> EnsureLanguageIsActive(string languageId,
/// <param name="templateName">Message template name</param>
/// <param name="toEmailAccount">Send email to email account</param>
/// <param name="customerNote">Customer note</param>
/// <param name="loginCode">(Optional) Login Code for inclusion within magic link email</param>
/// <returns>Queued email identifier</returns>
protected virtual async Task<int> SendCustomerMessage(Customer customer, Store store, string languageId, string templateName, bool toEmailAccount = false, CustomerNote customerNote = null)
protected virtual async Task<int> 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));

Expand All @@ -158,7 +161,8 @@ protected virtual async Task<int> 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
Expand Down Expand Up @@ -219,6 +223,20 @@ public virtual async Task<int> SendCustomerPasswordRecoveryMessage(Customer cust
return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerPasswordRecovery);
}

/// <summary>
/// Sends an e-mail login link to the customer
/// </summary>
/// <param name="customer">Customer</param>
/// <param name="store">Store</param>
/// <param name="languageId">Message language identifier</param>
/// <param name="loginCode">Login Code for inclusion within the URL</param>
/// <returns>Queued email identifier</returns>
public virtual async Task<int> SendCustomerEmailLoginLinkMessage(Customer customer, Store store, string languageId, string loginCode)
{
return await SendCustomerMessage(customer, store, languageId, MessageTemplateNames.CustomerEmailLoginCode, false, null, loginCode);
}


/// <summary>
/// Sends a new customer note added notification to a customer
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ProjectReference Include="..\..\Core\Grand.Infrastructure\Grand.Infrastructure.csproj" />
<ProjectReference Include="..\..\Core\Grand.SharedKernel\Grand.SharedKernel.csproj" />
<ProjectReference Include="..\Grand.Business.Core\Grand.Business.Core.csproj" />
<ProjectReference Include="..\Grand.Business.Messages\Grand.Business.Messages.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NPOI" Version="2.5.6" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<a href=\"{{Store.URL}}\">{{Store.Name}}</a> <br />\r\n <br />\r\n To login to {{Store.Name}} <a href=\"{{Customer.LoginCodeURL}}\">click here</a>. <br />\r\n <br />\r\n {{Store.Name}}",
IsActive = true,
EmailAccountId = eaGeneral.Id,
},
new MessageTemplate
{
Name = "Customer.WelcomeMessage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public partial class InstallationService : IInstallationService
Disallow: /order/*
Disallow: /orderdetails
Disallow: /passwordrecovery/confirm
Disallow: /LoginWithMagicLink
Disallow: /popupinteractiveform
Disallow: /register/*
Disallow: /merchandisereturn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ protected virtual async Task InstallSettings(bool installSampleData)
AllowUsersToDeleteAccount = false,
AllowUsersToExportData = false,
TwoFactorAuthenticationEnabled = false,
LoginWithMagicLinkEnabled = true,
LoginCodeMinutesToExpire = 10
});

await _settingService.SaveSetting(new AddressSettings {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

/// <summary>
/// Upgrade process
/// </summary>
/// <param name="database"></param>
/// <param name="serviceProvider"></param>
/// <returns></returns>
public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider)
{
var repository = serviceProvider.GetRequiredService<ISettingService>();
var logService = serviceProvider.GetRequiredService<ILogger>();

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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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";

/// <summary>
/// Upgrade process
/// </summary>
/// <param name="database"></param>
/// <param name="serviceProvider"></param>
/// <returns></returns>
public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider)
{
var messageRepository = serviceProvider.GetRequiredService<IMessageTemplateService>();
var emailRepository = serviceProvider.GetRequiredService<IEmailAccountService>();

var logService = serviceProvider.GetRequiredService<ILogger>();

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 = "<a href=\"{{Store.URL}}\">{{Store.Name}}</a> <br />\r\n <br />\r\n To login to {{Store.Name}} <a href=\"{{Customer.LoginCodeURL}}\">click here</a>. <br />\r\n <br />\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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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";

/// <summary>
/// Upgrade process
/// </summary>
/// <param name="database"></param>
/// <param name="serviceProvider"></param>
/// <returns></returns>
public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider)
{
return serviceProvider.ImportLanguageResourcesFromXml("App_Data/Resources/Upgrade/en_201.xml");
}
}
}
Original file line number Diff line number Diff line change
@@ -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";

/// <summary>
/// Upgrade process
/// </summary>
/// <param name="database"></param>
/// <param name="serviceProvider"></param>
/// <returns></returns>
public bool UpgradeProcess(IDatabaseContext database, IServiceProvider serviceProvider)
{
var repository = serviceProvider.GetRequiredService<IRepository<GrandNodeVersion>>();

var dbversion = repository.Table.ToList().FirstOrDefault();
dbversion.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}";
repository.Update(dbversion);

return true;
}
}
}
Loading