mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 13:32:13 +00:00
- 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.
99 lines
3.9 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|