88 lines
3.4 KiB
C#
88 lines
3.4 KiB
C#
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<GenericRes> _localizer;
|
||
|
|
readonly ILogger<RecoveryJob> _logger;
|
||
|
|
|
||
|
|
public RecoveryJob(AppConfigurationService app, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> 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<JobOutcome> Handle(Job job, CancellationToken token)
|
||
|
|
{
|
||
|
|
var payload = JsonSerializer.Deserialize<RecoveryPayload>(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<RootUser>().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<EmailRecovery>(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");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|