shoc-backend/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs
Alexandre Brandizzi 77a10e38ca fix(auth): cap reset abuse per account, key code hashes, send reset email off the request path
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and
  an account gets 10 failed code checks a day across every code it is sent,
  so new client addresses and new codes no longer buy more guesses. Refused
  requests answer exactly like accepted ones.
- The reset email is queued to a background sender, and unregistered
  addresses store a row no code can match, so both paths do the same work
  and return without waiting on the mail provider. Each request also clears
  expired codes.
- Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT
  signing secret; rows in the previous unkeyed format stop matching.
- Email and code are read only from the JSON body.
2026-09-25 13:00:03 -03:00

99 lines
3.9 KiB
C#

using System.Threading.Channels;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.HostedServices
{
public static class PasswordResetEmailDelivery
{
/// <summary>
/// Registers the process-wide reset email queue and the background service that
/// drains it. Both must be singletons: the request and the sender share one channel.
/// </summary>
public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services)
{
services.AddSingleton<PasswordResetEmailChannel>();
services.AddSingleton<IPasswordResetEmailQueue>(provider => provider.GetRequiredService<PasswordResetEmailChannel>());
services.AddHostedService<PasswordResetEmailSenderHostedService>();
return services;
}
}
public sealed record PasswordResetEmail(string EmailTo, string Subject, string Body);
public sealed class PasswordResetEmailChannel : IPasswordResetEmailQueue
{
public const int Capacity = 1000;
private readonly Channel<PasswordResetEmail> _channel = Channel.CreateBounded<PasswordResetEmail>(
new BoundedChannelOptions(Capacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true
});
private readonly ILogger<PasswordResetEmailChannel> _logger;
private int _pending;
public PasswordResetEmailChannel(ILogger<PasswordResetEmailChannel> logger)
{
_logger = logger;
}
public ChannelReader<PasswordResetEmail> Reader => _channel.Reader;
/// <summary>Emails accepted and not yet handed to the mail provider.</summary>
public int Pending => Volatile.Read(ref _pending);
public bool TryEnqueue(string emailTo, string subject, string body)
{
Interlocked.Increment(ref _pending);
if (_channel.Writer.TryWrite(new PasswordResetEmail(emailTo, subject, body)))
return true;
Interlocked.Decrement(ref _pending);
_logger.LogWarning("Password reset email queue is full; an email was dropped.");
return false;
}
public void MarkHandled() => Interlocked.Decrement(ref _pending);
}
public sealed class PasswordResetEmailSenderHostedService : BackgroundService
{
private readonly PasswordResetEmailChannel _channel;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PasswordResetEmailSenderHostedService> _logger;
public PasswordResetEmailSenderHostedService(
PasswordResetEmailChannel channel,
IServiceScopeFactory scopeFactory,
ILogger<PasswordResetEmailSenderHostedService> logger)
{
_channel = channel;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
if (!await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
}
catch (Exception ex)
{
// The message can echo the recipient or the body, so only the type is logged.
_logger.LogError("Password reset email failed with {ExceptionType}.", ex.GetType().FullName);
}
finally
{
_channel.MarkHandled();
}
}
}
}
}