using MailKit.Net.Smtp; using Microsoft.Extensions.Localization; using MimeKit; using MongoDB.Entities; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Jobs; using PrivaPub.Models.User; using PrivaPub.Resources; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace PrivaPub.Services { public sealed record RecoveryPayload(string RootUserId, string Host); // Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer // to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them. // The code exists only in the email: the database keeps its hash, for an hour. public class RecoveryJob : IJobHandler { public const string Host = "recovery"; public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1); readonly AppConfigurationService _app; readonly IStringLocalizer _localizer; readonly ILogger _logger; public RecoveryJob(AppConfigurationService app, IStringLocalizer localizer, ILogger logger) { _app = app; _localizer = localizer; _logger = logger; } public JobKind Kind => JobKind.SendRecovery; public int Concurrency => 1; public int MaxAttempts => 3; public int PerHostLimit => 1; public static string Hash(string code) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(code ?? string.Empty))); public async Task Handle(Job job, CancellationToken token) { var payload = JsonSerializer.Deserialize(job.Payload); if (string.IsNullOrEmpty(payload?.RootUserId)) return JobOutcome.Done;//asked for an account that does not exist: there is nobody to write to var user = await DB.Default.Find().MatchID(payload.RootUserId).ExecuteFirstAsync(token); if (user is not { DeletedAt: null, IsBanned: false } || string.IsNullOrEmpty(user.Email)) return JobOutcome.Done; var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); await DB.Default.DeleteAsync(r => r.RootUserId == user.ID); await DB.Default.SaveAsync(new EmailRecovery { RootUserId = user.ID, CodeHash = Hash(code), ExpiresAt = DateTime.UtcNow + CodeLifetime }, token); var email = _app.AppConfiguration.EmailConfiguration; var message = new MimeMessage(); message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername)); message.To.Add(MailboxAddress.Parse(user.Email)); message.Subject = _localizer["PrivaPub - Password recovery link"]; message.Body = new TextPart("plain") { Text = string.Format(_localizer[ "Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."], user.UserName, $"{payload.Host}/password-recovery?rc={code}") }; try { using var smtp = new SmtpClient(); await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token); await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token); await smtp.SendAsync(message, token); await smtp.DisconnectAsync(quit: true, token); return JobOutcome.Done; } catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested) { _logger.LogWarning(ex, "The recovery email could not be sent"); return JobOutcome.Retry("smtp"); } } } }