diff --git a/Crypter.Core/Exceptions/HangfireJobException.cs b/Crypter.Core/Exceptions/HangfireJobException.cs new file mode 100644 index 000000000..11611d089 --- /dev/null +++ b/Crypter.Core/Exceptions/HangfireJobException.cs @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; + +namespace Crypter.Core.Exceptions; + +public class HangfireJobException : Exception +{ + public HangfireJobException(string message) : base(message) + { } +} diff --git a/Crypter.Core/Features/UserEmailVerification/Commands/SendVerificationEmailCommand.cs b/Crypter.Core/Features/UserEmailVerification/Commands/SendVerificationEmailCommand.cs new file mode 100644 index 000000000..e20f9c4c7 --- /dev/null +++ b/Crypter.Core/Features/UserEmailVerification/Commands/SendVerificationEmailCommand.cs @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2023 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Crypter.Core.Models; +using Crypter.Core.Services; +using Crypter.Crypto.Common; +using Crypter.DataAccess; +using Crypter.DataAccess.Entities; +using EasyMonads; +using MediatR; + +namespace Crypter.Core.Features.UserEmailVerification.Commands; + +/// +/// A command that will attempt to send a verification email to the provided +/// user. +/// +/// Returns 'true' if the email was successfully sent or if the user's current +/// state is such that they should not receive a verification email. +/// +/// Returns 'false' if the email should be sent, but failed to send. +/// +/// +public sealed record SendVerificationEmailCommand(Guid UserId) : IRequest; + +internal sealed class SendVerificationEmailCommandHandler + : IRequestHandler +{ + private readonly ICryptoProvider _cryptoProvider; + private readonly DataContext _dataContext; + private readonly IEmailService _emailService; + + public SendVerificationEmailCommandHandler( + ICryptoProvider cryptoProvider, + DataContext dataContext, + IEmailService emailService) + { + _cryptoProvider = cryptoProvider; + _dataContext = dataContext; + _emailService = emailService; + } + + public async Task Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken) + { + return await UserEmailVerificationQueries + .GenerateVerificationParametersAsync(_dataContext, _cryptoProvider, request.UserId) + .BindAsync(async parameters => + { + bool deliverySuccess = await _emailService.SendEmailVerificationAsync(parameters); + if (deliverySuccess) + { + UserEmailVerificationEntity newEntity = new UserEmailVerificationEntity(parameters.UserId, + parameters.VerificationCode, parameters.VerificationKey, DateTime.UtcNow); + _dataContext.UserEmailVerifications.Add(newEntity); + await _dataContext.SaveChangesAsync(CancellationToken.None); + } + + return deliverySuccess; + }).SomeOrDefaultAsync(true); + } +} diff --git a/Crypter.Core/Features/UserEmailVerification/UserEmailVerificationCommands.cs b/Crypter.Core/Features/UserEmailVerification/UserEmailVerificationCommands.cs index 981ae1236..4909c3dfc 100644 --- a/Crypter.Core/Features/UserEmailVerification/UserEmailVerificationCommands.cs +++ b/Crypter.Core/Features/UserEmailVerification/UserEmailVerificationCommands.cs @@ -26,7 +26,6 @@ using System; using System.Threading.Tasks; -using Crypter.Core.Models; using Crypter.DataAccess; using Crypter.DataAccess.Entities; using Microsoft.EntityFrameworkCore; @@ -35,15 +34,6 @@ namespace Crypter.Core.Features.UserEmailVerification; internal static class UserEmailVerificationCommands { - internal static Task SaveVerificationParametersAsync(DataContext dataContext, - UserEmailAddressVerificationParameters parameters) - { - UserEmailVerificationEntity newEntity = new UserEmailVerificationEntity(parameters.UserId, - parameters.VerificationCode, parameters.VerificationKey, DateTime.UtcNow); - dataContext.UserEmailVerifications.Add(newEntity); - return dataContext.SaveChangesAsync(); - } - internal static async Task DeleteUserEmailVerificationEntity(DataContext dataContext, Guid userId, bool saveChanges) { UserEmailVerificationEntity foundEntity = await dataContext.UserEmailVerifications diff --git a/Crypter.Core/Services/EmailService.cs b/Crypter.Core/Services/EmailService.cs index dbbef6eea..e5a296d7d 100644 --- a/Crypter.Core/Services/EmailService.cs +++ b/Crypter.Core/Services/EmailService.cs @@ -34,6 +34,7 @@ using MailKit.Security; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; @@ -63,11 +64,13 @@ public static void AddEmailService(this IServiceCollection services, Action _settings; + private readonly ILogger _logger; - public EmailService(IOptions emailSettings) + public EmailService(IOptions emailSettings, ILogger logger) { - Settings = emailSettings.Value; + _settings = emailSettings; + _logger = logger; } /// @@ -80,13 +83,13 @@ public EmailService(IOptions emailSettings) /// public virtual async Task SendAsync(string subject, string message, EmailAddress recipient) { - if (!Settings.Enabled) + if (!_settings.Value.Enabled) { return true; } var mailMessage = new MimeMessage(); - mailMessage.From.Add(MailboxAddress.Parse(Settings.From)); + mailMessage.From.Add(MailboxAddress.Parse(_settings.Value.From)); mailMessage.To.Add(MailboxAddress.Parse(recipient.Value)); mailMessage.Subject = subject; mailMessage.Body = new TextPart("plain") @@ -98,13 +101,13 @@ public virtual async Task SendAsync(string subject, string message, EmailA try { - await smtpClient.ConnectAsync(Settings.Host, Settings.Port, SecureSocketOptions.StartTls); - await smtpClient.AuthenticateAsync(Settings.Username, Settings.Password); + await smtpClient.ConnectAsync(_settings.Value.Host, _settings.Value.Port, SecureSocketOptions.StartTls); + await smtpClient.AuthenticateAsync(_settings.Value.Username, _settings.Value.Password); await smtpClient.SendAsync(mailMessage); } catch (Exception ex) { - Console.WriteLine(ex.Message); + _logger.LogError("An error occured while sending an email: {error}", ex.Message); return false; } finally @@ -130,9 +133,7 @@ public async Task SendAccountRecoveryLinkAsync(UserRecoveryParameters para /// /// Send a verification email to the provided recipient. /// - /// - /// - /// + /// /// This method is 'virtual' to enable some unit tests. /// public virtual async Task SendEmailVerificationAsync(UserEmailAddressVerificationParameters parameters) diff --git a/Crypter.Core/Services/HangfireBackgroundService.cs b/Crypter.Core/Services/HangfireBackgroundService.cs index a13346cac..83e8e988e 100644 --- a/Crypter.Core/Services/HangfireBackgroundService.cs +++ b/Crypter.Core/Services/HangfireBackgroundService.cs @@ -30,11 +30,12 @@ using System.Threading.Tasks; using Crypter.Common.Enums; using Crypter.Common.Primitives; +using Crypter.Core.Exceptions; using Crypter.Core.Features.AccountRecovery; using Crypter.Core.Features.Keys.Commands; using Crypter.Core.Features.Transfer; using Crypter.Core.Features.UserAuthentication; -using Crypter.Core.Features.UserEmailVerification; +using Crypter.Core.Features.UserEmailVerification.Commands; using Crypter.Core.Features.UserToken; using Crypter.Core.LinqExpressions; using Crypter.Core.Repositories; @@ -43,13 +44,16 @@ using Crypter.DataAccess.Entities; using EasyMonads; using Hangfire; +using MediatR; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Unit = EasyMonads.Unit; namespace Crypter.Core.Services; public interface IHangfireBackgroundService { - Task SendEmailVerificationAsync(Guid userId); + Task SendEmailVerificationAsync(Guid userId); Task SendTransferNotificationAsync(Guid itemId, TransferItemType itemType); Task SendRecoveryEmailAsync(string emailAddress); @@ -85,35 +89,45 @@ Task DeleteTransferAsync(Guid itemId, TransferItemType itemType, TransferUserTyp public class HangfireBackgroundService : IHangfireBackgroundService { private readonly DataContext _dataContext; + private readonly ISender _sender; private readonly IBackgroundJobClient _backgroundJobClient; private readonly ICryptoProvider _cryptoProvider; private readonly IEmailService _emailService; private readonly ITransferRepository _transferRepository; + private readonly ILogger _logger; - private const int _accountRecoveryEmailExpirationMinutes = 30; + private const int AccountRecoveryEmailExpirationMinutes = 30; public HangfireBackgroundService( - DataContext dataContext, IBackgroundJobClient backgroundJobClient, ICryptoProvider cryptoProvider, - IEmailService emailService, ITransferRepository transferRepository) + DataContext dataContext, + ISender sender, + IBackgroundJobClient backgroundJobClient, + ICryptoProvider cryptoProvider, + IEmailService emailService, + ITransferRepository transferRepository, + ILogger logger) { _dataContext = dataContext; + _sender = sender; _backgroundJobClient = backgroundJobClient; _cryptoProvider = cryptoProvider; _emailService = emailService; _transferRepository = transferRepository; + _logger = logger; } - public Task SendEmailVerificationAsync(Guid userId) + public async Task SendEmailVerificationAsync(Guid userId) { - return UserEmailVerificationQueries.GenerateVerificationParametersAsync(_dataContext, _cryptoProvider, userId) - .IfSomeAsync(async x => - { - bool deliverySuccess = await _emailService.SendEmailVerificationAsync(x); - if (deliverySuccess) - { - await UserEmailVerificationCommands.SaveVerificationParametersAsync(_dataContext, x); - } - }); + var request = new SendVerificationEmailCommand(userId); + var result = await _sender.Send(request); + + if (!result) + { + _logger.LogError("Failed to send verification email for user: {userId}", userId); + throw new HangfireJobException($"{nameof(SendEmailVerificationAsync)} failed"); + } + + return Unit.Default; } public async Task SendTransferNotificationAsync(Guid itemId, TransferItemType itemType) @@ -145,13 +159,13 @@ public Task SendRecoveryEmailAsync(string emailAddress) { bool deliverySuccess = await _emailService.SendAccountRecoveryLinkAsync(parameters, - _accountRecoveryEmailExpirationMinutes); + AccountRecoveryEmailExpirationMinutes); if (deliverySuccess) { DateTime utcNow = DateTime.UtcNow; await UserRecoveryCommands.SaveRecoveryParametersAsync(_dataContext, parameters, utcNow); - DateTime recoveryExpiration = utcNow.AddMinutes(_accountRecoveryEmailExpirationMinutes); + DateTime recoveryExpiration = utcNow.AddMinutes(AccountRecoveryEmailExpirationMinutes); _backgroundJobClient.Schedule(() => DeleteRecoveryParametersAsync(parameters.UserId), recoveryExpiration); } @@ -180,9 +194,10 @@ public Task DeleteRecoveryParametersAsync(Guid userId) return UserRecoveryCommands.DeleteRecoveryParametersAsync(_dataContext, userId); } - public Task DeleteUserKeysAsync(Guid userId) + public async Task DeleteUserKeysAsync(Guid userId) { - return DeleteUserKeysCommandHandler.HandleAsync(_dataContext, userId); + var request = new DeleteUserKeysCommand(userId); + return await _sender.Send(request); } public async Task DeleteReceivedTransfersAsync(Guid userId) diff --git a/Crypter.Test/Core_Tests/Services_Tests/EmailService_Tests.cs b/Crypter.Test/Core_Tests/Services_Tests/EmailService_Tests.cs index 319815c46..fc9e18c24 100644 --- a/Crypter.Test/Core_Tests/Services_Tests/EmailService_Tests.cs +++ b/Crypter.Test/Core_Tests/Services_Tests/EmailService_Tests.cs @@ -28,6 +28,8 @@ using Crypter.Common.Primitives; using Crypter.Core.Services; using Crypter.Core.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NUnit.Framework; @@ -37,18 +39,25 @@ namespace Crypter.Test.Core_Tests.Services_Tests; public class EmailService_Tests { private IOptions _defaultEmailSettings; + private ILogger _logger; - [OneTimeSetUp] - public void OneTimeSetup() + [SetUp] + public void Setup() { EmailSettings settings = new EmailSettings(); _defaultEmailSettings = Options.Create(settings); + + var serviceProvider = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + var factory = serviceProvider.GetService(); + _logger = factory.CreateLogger(); } [Test] public async Task ServiceDisabled_SendAsync_ReturnsTrue() { - var sut = new EmailService(_defaultEmailSettings); + var sut = new EmailService(_defaultEmailSettings, _logger); var emailAddress = EmailAddress.From("jack@crypter.dev"); var result = await sut.SendAsync("foo", "bar", emailAddress); Assert.IsTrue(result); diff --git a/Crypter.Test/Core_Tests/Services_Tests/HangfireBackgroundService_Tests.cs b/Crypter.Test/Core_Tests/Services_Tests/HangfireBackgroundService_Tests.cs index 05fa99662..b2c50ed53 100644 --- a/Crypter.Test/Core_Tests/Services_Tests/HangfireBackgroundService_Tests.cs +++ b/Crypter.Test/Core_Tests/Services_Tests/HangfireBackgroundService_Tests.cs @@ -34,8 +34,10 @@ using Crypter.Crypto.Providers.Default; using Crypter.DataAccess; using Hangfire; +using MediatR; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; @@ -47,8 +49,10 @@ public class HangfireBackgroundService_Tests private WebApplicationFactory _factory; private IServiceScope _scope; private DataContext _dataContext; - + private ISender _sender; private ICryptoProvider _cryptoProvider; + private ILogger _logger; + private Mock _backgroundJobClientMock; private Mock _emailServiceMock; private Mock _transferStorageMock; @@ -66,6 +70,10 @@ public async Task SetupTestAsync() _scope = _factory.Services.CreateScope(); _dataContext = _scope.ServiceProvider.GetRequiredService(); + _sender = _scope.ServiceProvider.GetRequiredService(); + + var factory = _scope.ServiceProvider.GetRequiredService(); + _logger = factory.CreateLogger(); } [TearDown] @@ -84,8 +92,8 @@ public async Task Verification_Email_Not_Sent_Without_Verification_Parameters() It.IsAny())) .ReturnsAsync((UserEmailAddressVerificationParameters parameters) => true); - HangfireBackgroundService sut = new HangfireBackgroundService(_dataContext, _backgroundJobClientMock.Object, - _cryptoProvider, _emailServiceMock.Object, _transferStorageMock.Object); + HangfireBackgroundService sut = new HangfireBackgroundService(_dataContext, _sender, _backgroundJobClientMock.Object, + _cryptoProvider, _emailServiceMock.Object, _transferStorageMock.Object, _logger); await sut.SendEmailVerificationAsync(Guid.NewGuid()); _emailServiceMock.Verify(x => x.SendEmailVerificationAsync(It.IsAny()), @@ -101,8 +109,8 @@ public async Task Recovery_Email_Not_Sent_Without_Recovery_Parameters() It.IsAny())) .ReturnsAsync((UserRecoveryParameters parameters, int expirationMinutes) => true); - HangfireBackgroundService sut = new HangfireBackgroundService(_dataContext, _backgroundJobClientMock.Object, - _cryptoProvider, _emailServiceMock.Object, _transferStorageMock.Object); + HangfireBackgroundService sut = new HangfireBackgroundService(_dataContext, _sender, _backgroundJobClientMock.Object, + _cryptoProvider, _emailServiceMock.Object, _transferStorageMock.Object, _logger); await sut.SendRecoveryEmailAsync("foo@test.com"); _emailServiceMock.Verify(