This repository was archived by the owner on Jun 28, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3
feat: polly-based rate limiter for limiting amount of emails that could be sent for passwrod reset/email-confirm #2
Open
Fildrance
wants to merge
6
commits into
Space-Wizards-Federation:master
Choose a base branch
from
Fildrance:feature/rate-limit-email
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8ab2e5c
feat: polly-based rate limiter for limiting amount of emails that cou…
Fildrance ba0e4a8
feat: changed global rate limit for emails to use Polly rate limiter
Fildrance cc1f546
refactor: xml-doc, code simplification, logging for when rate limit w…
Fildrance 8f3ebf4
fix: Admin\ViewUser should now properly handle fail-to-send confirm e…
Fildrance a3e6826
refactor: add proper error message for failure to send confirmation e…
Fildrance b0bad0d
refactor: removed 'purpose' part of rate-limiting cache key
Fildrance File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,4 +7,5 @@ riderModule.iml | |
| /*.sln.DotSettings.user | ||
|
|
||
| */appsettings.Secret.yml | ||
| */tempkey.jwk | ||
| */tempkey.jwk | ||
| /.vs/* | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
78
SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| })!; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?