Skip to content
This repository was archived by the owner on Jun 28, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ riderModule.iml
/*.sln.DotSettings.user

*/appsettings.Secret.yml
*/tempkey.jwk
*/tempkey.jwk
/.vs/*
8 changes: 4 additions & 4 deletions SS14.Auth.Shared/Emails/DummyEmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ namespace SS14.Auth.Shared.Emails;

public class DummyEmailSender : IRawEmailSender
{
public Task SendEmailAsync(string email, string subject, string htmlMessage)
public ValueTask<EmailSendingResult> SendEmailAsync(string email, string subject, string htmlMessage)
{
Console.WriteLine("Would send email to {0}:\n" +
"Subject: {1}\n" +
"Body: {2}", email, subject, htmlMessage);

File.WriteAllText("Email.html", htmlMessage);
return Task.CompletedTask;

return ValueTask.FromResult(EmailSendingResult.Success());
}
}
}
29 changes: 29 additions & 0 deletions SS14.Auth.Shared/Emails/EmailHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Text.Encodings.Web;
using System.Threading.Tasks;

namespace SS14.Auth.Shared.Emails;

public class EmailHelper(IEmailSender sender) : IEmailHelper
{
/// <inheritdoc/>
public async ValueTask<EmailSendingResult> SendConfirmEmail(string address, string confirmLink)
{
return await sender.SendEmailAsync(
address,
"Confirm your Space Station 14 account",
$"Please confirm your account by <a href='{confirmLink}'>clicking here</a>." +
$"\n<p><small>If the above link is not working, try this one {HtmlEncoder.Default.Encode(confirmLink)}</small></p>");
}

/// <inheritdoc/>
public async ValueTask<EmailSendingResult> SendResetEmail(string address, string callbackUrl)
{
return await sender.SendEmailAsync(
address,
"Reset Password",
"A password reset has been requested for your account.<br />" +
$"If you did indeed request this, <a href='{callbackUrl}'>click here</a> to reset your password.<br />" +
"If you did not request this, simply ignore this email." +
$"\n<p><small>If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)}</small></p");
}
}
78 changes: 78 additions & 0 deletions SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Microsoft.Extensions.Caching.Memory;
using Polly;
using Polly.RateLimiting;
using System;
using System.Threading.RateLimiting;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace SS14.Auth.Shared.Emails;

/// <summary>
/// Decorator for <see cref="EmailHelper"/>, which rate limits email sending on per-user basis.
/// This is to prevent abuse of the email system, such as spamming password reset emails or confirmation emails.
/// This is important due to us having global limits on amount of emails we can send.
/// </summary>
public class EmailHelperRateLimitingDecorator(IEmailHelper inner, IMemoryCache cache, IOptions<LimitOptions> limitOptions, ILogger<EmailHelperRateLimitingDecorator> logger) : IEmailHelper
{
/// <inheritdoc/>
public async ValueTask<EmailSendingResult> SendConfirmEmail(string address, string confirmLink)
{
var pipeline = GetOrCreatePipeline(address, "confirm");
try
{
return await pipeline.ExecuteAsync(t => inner.SendConfirmEmail(address, confirmLink));
}
catch (RateLimiterRejectedException)
{
return EmailSendingResult.Failure("Your rate limit for confirmation emails was reached, you can try again later.");
}
}

/// <inheritdoc/>
public async ValueTask<EmailSendingResult> SendResetEmail(string address, string callbackUrl)
{
var pipeline = GetOrCreatePipeline(address, "reset");
try
{
return await pipeline.ExecuteAsync(t => inner.SendResetEmail(address, callbackUrl));
}
catch (RateLimiterRejectedException)
{
return EmailSendingResult.Failure("Your rate limit for password reset emails was reached, you can try again later.");
}
}

private ResiliencePipeline GetOrCreatePipeline(string address, string purpose)
{
var cacheKey = $"email_pipeline_{address}";

return cache.GetOrCreate(cacheKey, entry =>
{
var options = limitOptions.Value;

entry.SlidingExpiration = options.RefreshWindowPerUser.Add(TimeSpan.FromMinutes(5));

var rateLimiter = new SlidingWindowRateLimiter(
new SlidingWindowRateLimiterOptions
{
PermitLimit = options.MaxEmailsInWindowPerUser,
Window = options.RefreshWindowPerUser,
SegmentsPerWindow = 4,
});
return new ResiliencePipelineBuilder()
.AddRateLimiter(
new RateLimiterStrategyOptions
{
RateLimiter = _ => rateLimiter.AcquireAsync(),
OnRejected = _ =>
{
logger.LogWarning("Rate limit rejected for user: {Address}, purpose: {Purpose}.", address, purpose);
return ValueTask.CompletedTask;
}
}
).Build();
})!;
}
}
57 changes: 41 additions & 16 deletions SS14.Auth.Shared/Emails/EmailSender.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,60 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SS14.Auth.Shared.MutexDb;
using Polly;
using Polly.RateLimiting;
using System.Threading.RateLimiting;
using System.Threading.Tasks;

namespace SS14.Auth.Shared.Emails;

public sealed class EmailSender : IEmailSender
{
private readonly IRawEmailSender _rawEmailSender;
private readonly MutexDatabase _mutex;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MutexDatabase is not in use now - should i remove it? It looks kinda neat. It also is related to table for storing data - should i remove it too?

private readonly IOptions<LimitOptions> _limits;
private readonly LimitOptions _limits;
private readonly ILogger<EmailSender> _logger;
private readonly ResiliencePipeline _pipeline;

public EmailSender(IRawEmailSender rawEmailSender, MutexDatabase mutex, IOptions<LimitOptions> limits, ILogger<EmailSender> logger)
public EmailSender(IRawEmailSender rawEmailSender, IOptions<LimitOptions> limits, ILogger<EmailSender> logger)
{
_rawEmailSender = rawEmailSender;
_mutex = mutex;
_limits = limits;
_limits = limits.Value;
_logger = logger;

var rateLimiter = new SlidingWindowRateLimiter(
new SlidingWindowRateLimiterOptions
{
PermitLimit = _limits.GlobalMaxEmail,
Window = _limits.GlobalEmailWindow,
SegmentsPerWindow = 4
});
_pipeline = new ResiliencePipelineBuilder()
.AddRateLimiter(
new RateLimiterStrategyOptions
{
RateLimiter = _ => rateLimiter.AcquireAsync(),
OnRejected = _ =>
{
_logger.LogCritical(
"HIT EMAIL LIMIT!!! Current settings are window: {window}, max per window:{maxEmail}",
_limits.GlobalEmailWindow,
_limits.GlobalMaxEmail);
return ValueTask.CompletedTask;
},
}
)
.Build();
}

public async Task SendEmailAsync(string email, string subject, string htmlMessage)
public async ValueTask<EmailSendingResult> SendEmailAsync(string email, string subject, string htmlMessage)
{
var count = _mutex.IncCount("Email");

if (count >= _limits.Value.MaxEmailsPerHour)
try
{
await _pipeline.ExecuteAsync(context => _rawEmailSender.SendEmailAsync(email, subject, htmlMessage));
return EmailSendingResult.Success();
}
catch (RateLimiterRejectedException)
{
_logger.LogCritical("HIT EMAIL LIMIT, no more emails will be sent for the rest of the hour!!!");
return;
return EmailSendingResult.Failure("Rate limit for emails was reached, please try again later.");
}

await _rawEmailSender.SendEmailAsync(email, subject, htmlMessage);
}
}
}
31 changes: 31 additions & 0 deletions SS14.Auth.Shared/Emails/EmailSendingResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;

namespace SS14.Auth.Shared.Emails;

/// <summary>
/// Wrapper for results of email sending. Can contain error message, success-wrapper is resued.
/// </summary>
public class EmailSendingResult
{
private static readonly EmailSendingResult SuccessSingleton = new(null);

private EmailSendingResult([CanBeNull] string error)
{
Error = error;
}

public bool HasError => Error != null;

[MemberNotNullWhen(true, nameof(HasError))] public string Error { get; }

public static EmailSendingResult Success()
{
return SuccessSingleton;
}

public static EmailSendingResult Failure(string error)
{
return new EmailSendingResult(error);
}
}
23 changes: 23 additions & 0 deletions SS14.Auth.Shared/Emails/IEmailHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using SS14.Auth.Shared.Data;

namespace SS14.Auth.Shared.Emails;

/// <summary>
/// Helper class for sending default (pre-defined) emails, such as confirmation and password reset emails.
/// </summary>
public interface IEmailHelper
{
/// <summary> Sends email for account confirmation. </summary>
ValueTask<EmailSendingResult> SendConfirmEmail(string address, string confirmLink);

/// <summary> Sends email for password reset. </summary>
ValueTask<EmailSendingResult> SendResetEmail(string address, string callbackUrl);

/// <summary> Creates new user object. </summary>
static SpaceUser CreateNewUser(string userName, string email, ISystemClock systemClock)
{
return new SpaceUser { UserName = userName, Email = email, CreatedTime = systemClock.UtcNow };
}
}
4 changes: 2 additions & 2 deletions SS14.Auth.Shared/Emails/IEmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ namespace SS14.Auth.Shared.Emails;
// AspNet's built-in one is marked as "for internal use" so...
public interface IEmailSender
{
public Task SendEmailAsync(string email, string subject, string htmlMessage);
}
ValueTask<EmailSendingResult> SendEmailAsync(string email, string subject, string htmlMessage);
}
5 changes: 3 additions & 2 deletions SS14.Auth.Shared/Emails/SmtpEmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public SmtpEmailSender(IOptions<SmtpEmailOptions> options)
_options = options.Value;
}

public async Task SendEmailAsync(string email, string subject, string htmlMessage)
public async ValueTask<EmailSendingResult> SendEmailAsync(string email, string subject, string htmlMessage)
{
using var client = new SmtpClient();
await client.ConnectAsync(_options.Host, _options.Port);
Expand All @@ -32,5 +32,6 @@ public async Task SendEmailAsync(string email, string subject, string htmlMessag
};

await client.SendAsync(msg);
return EmailSendingResult.Success();
}
}
}
30 changes: 27 additions & 3 deletions SS14.Auth.Shared/LimitOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
namespace SS14.Auth.Shared;
using System;

namespace SS14.Auth.Shared;

/// <summary>
/// Options for rate limiting email sending.
/// </summary>
public sealed class LimitOptions
{
public int MaxEmailsPerHour { get; set; } = 5000;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Limit name changed, as now it represents not per-hour but per-window limit - i think it should be okay?

}
/// <summary>
/// Time window for refreshing per-user email sending limits.
/// </summary>
public TimeSpan RefreshWindowPerUser { get; set; } = new(0, 0, 10);

/// <summary>
/// Maximum amount of emails that can be sent to a single user in the <see cref="RefreshWindowPerUser"/> time window.
/// </summary>
public int MaxEmailsInWindowPerUser { get; set; } = 10;


/// <summary>
/// Time window for refreshing global email sending limits.
/// </summary>
public TimeSpan GlobalEmailWindow { get; set; } = new(0, 1, 0);

/// <summary>
/// Maximum amount of emails that can be sent globally in the <see cref="GlobalEmailWindow"/> time window.
/// </summary>
public int GlobalMaxEmail { get; set; } = 5000;
}
32 changes: 0 additions & 32 deletions SS14.Auth.Shared/ModelShared.cs

This file was deleted.

1 change: 1 addition & 0 deletions SS14.Auth.Shared/SS14.Auth.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<PackageReference Include="IdentityServer4.EntityFramework.Storage" Version="4.1.2" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.3" />
<PackageReference Include="Polly.RateLimiting" Version="8.7.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading