From 8ab2e5cfa8c1f15d17054505b8e02ca37de66823 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Thu, 18 Jun 2026 17:06:53 +0300 Subject: [PATCH 1/6] feat: polly-based rate limiter for limiting amount of emails that could be sent for passwrod reset/email-confirm --- .gitignore | 3 +- SS14.Auth.Shared/Emails/EmailHelper.cs | 31 ++++++++ .../EmailHelperRateLimitingDecorator.cs | 75 +++++++++++++++++++ SS14.Auth.Shared/Emails/EmailSendingResult.cs | 28 +++++++ SS14.Auth.Shared/Emails/IEmailHelper.cs | 23 ++++++ SS14.Auth.Shared/LimitOptions.cs | 10 ++- SS14.Auth.Shared/ModelShared.cs | 32 -------- SS14.Auth.Shared/SS14.Auth.Shared.csproj | 1 + SS14.Auth.Shared/StartupHelpers.cs | 12 ++- SS14.Auth/Controllers/AuthApiController.cs | 13 ++-- .../Pages/Users/CreateReserved.cshtml.cs | 6 +- .../Admin/Pages/Users/ViewUser.cshtml.cs | 12 +-- .../Pages/Account/ExternalLogin.cshtml.cs | 13 ++-- .../Pages/Account/ForgotPassword.cshtml.cs | 20 +++-- .../Pages/Account/Manage/Email.cshtml.cs | 19 +++-- .../Identity/Pages/Account/Register.cshtml.cs | 16 ++-- .../Account/ResendEmailConfirmation.cshtml.cs | 14 ++-- 17 files changed, 250 insertions(+), 78 deletions(-) create mode 100644 SS14.Auth.Shared/Emails/EmailHelper.cs create mode 100644 SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs create mode 100644 SS14.Auth.Shared/Emails/EmailSendingResult.cs create mode 100644 SS14.Auth.Shared/Emails/IEmailHelper.cs delete mode 100644 SS14.Auth.Shared/ModelShared.cs diff --git a/.gitignore b/.gitignore index c2a3f28..e66163d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ riderModule.iml /*.sln.DotSettings.user */appsettings.Secret.yml -*/tempkey.jwk \ No newline at end of file +*/tempkey.jwk +/.vs/* diff --git a/SS14.Auth.Shared/Emails/EmailHelper.cs b/SS14.Auth.Shared/Emails/EmailHelper.cs new file mode 100644 index 0000000..e1acf7b --- /dev/null +++ b/SS14.Auth.Shared/Emails/EmailHelper.cs @@ -0,0 +1,31 @@ +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace SS14.Auth.Shared.Emails; + +public class EmailHelper(IEmailSender sender) : IEmailHelper +{ + /// + public async ValueTask SendConfirmEmail(string address, string confirmLink) + { + await sender.SendEmailAsync( + address, + "Confirm your Space Station 14 account", + $"Please confirm your account by clicking here." + + $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(confirmLink)}

"); + return EmailSendingResult.Success(); + } + + /// + public async ValueTask SendResetEmail(string address, string callbackUrl) + { + await sender.SendEmailAsync( + address, + "Reset Password", + "A password reset has been requested for your account.
" + + $"If you did indeed request this, click here to reset your password.
" + + "If you did not request this, simply ignore this email." + + $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)} +/// Decorator for , 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. +/// +public class EmailHelperRateLimitingDecorator(IEmailHelper inner, IMemoryCache cache, IOptions limitOptions) : IEmailHelper +{ + /// + public async ValueTask SendConfirmEmail(string address, string confirmLink) + { + var pipeline = GetOrCreatePipeline(address, "confirm"); + EmailSendingResult value; + try + { + value = 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."); + } + + return value; + } + + /// + public async ValueTask SendResetEmail(string address, string callbackUrl) + { + var pipeline = GetOrCreatePipeline(address, "reset"); + EmailSendingResult value; + try + { + value = 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."); + } + + return value; + } + + private ResiliencePipeline GetOrCreatePipeline(string address, string purpose) + { + var cacheKey = $"email_pipeline_{purpose}_{address}"; + + return cache.GetOrCreate(cacheKey, entry => + { + var options = limitOptions.Value; + + entry.SlidingExpiration = options.RefreshWindowPerUser.Add(TimeSpan.FromMinutes(5)); + + return new ResiliencePipelineBuilder() + .AddRateLimiter( + new SlidingWindowRateLimiter( + new SlidingWindowRateLimiterOptions + { + PermitLimit = options.MaxEmailsInWindowPerUser, + Window = options.RefreshWindowPerUser, + SegmentsPerWindow = 4 + }) + ).Build(); + })!; + } +} + diff --git a/SS14.Auth.Shared/Emails/EmailSendingResult.cs b/SS14.Auth.Shared/Emails/EmailSendingResult.cs new file mode 100644 index 0000000..be887c5 --- /dev/null +++ b/SS14.Auth.Shared/Emails/EmailSendingResult.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace SS14.Auth.Shared.Emails; + +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); + } +} diff --git a/SS14.Auth.Shared/Emails/IEmailHelper.cs b/SS14.Auth.Shared/Emails/IEmailHelper.cs new file mode 100644 index 0000000..9db7cbd --- /dev/null +++ b/SS14.Auth.Shared/Emails/IEmailHelper.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using SS14.Auth.Shared.Data; + +namespace SS14.Auth.Shared.Emails; + +///

+/// Helper class for sending default (pre-defined) emails, such as confirmation and password reset emails. +/// +public interface IEmailHelper +{ + /// Sends email for account confirmation. + ValueTask SendConfirmEmail(string address, string confirmLink); + + /// Sends email for password reset. + ValueTask SendResetEmail(string address, string callbackUrl); + + /// Creates new user object. + static SpaceUser CreateNewUser(string userName, string email, ISystemClock systemClock) + { + return new SpaceUser { UserName = userName, Email = email, CreatedTime = systemClock.UtcNow }; + } +} diff --git a/SS14.Auth.Shared/LimitOptions.cs b/SS14.Auth.Shared/LimitOptions.cs index f39924c..a87d8ec 100644 --- a/SS14.Auth.Shared/LimitOptions.cs +++ b/SS14.Auth.Shared/LimitOptions.cs @@ -1,6 +1,12 @@ -namespace SS14.Auth.Shared; +using System; + +namespace SS14.Auth.Shared; public sealed class LimitOptions { public int MaxEmailsPerHour { get; set; } = 5000; -} \ No newline at end of file + + public TimeSpan RefreshWindowPerUser { get; set; } = new(0, 0, 10); + + public int MaxEmailsInWindowPerUser { get; set; } = 10; +} diff --git a/SS14.Auth.Shared/ModelShared.cs b/SS14.Auth.Shared/ModelShared.cs deleted file mode 100644 index 155367e..0000000 --- a/SS14.Auth.Shared/ModelShared.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using SS14.Auth.Shared.Data; -using SS14.Auth.Shared.Emails; - -namespace SS14.Auth.Shared; - -public static class ModelShared -{ - public static async Task SendConfirmEmail(IEmailSender sender, string address, string confirmLink) - { - await sender.SendEmailAsync(address, "Confirm your Space Station 14 account", - $"Please confirm your account by clicking here." + - $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(confirmLink)}

"); - } - - public static async Task SendResetEmail(IEmailSender emailSender, string email, string callbackUrl) - { - await emailSender.SendEmailAsync( - email, "Reset Password", - "A password reset has been requested for your account.
" + - $"If you did indeed request this, click here to reset your password.
" + - "If you did not request this, simply ignore this email." + - $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)} + diff --git a/SS14.Auth.Shared/StartupHelpers.cs b/SS14.Auth.Shared/StartupHelpers.cs index b8594bc..305f553 100644 --- a/SS14.Auth.Shared/StartupHelpers.cs +++ b/SS14.Auth.Shared/StartupHelpers.cs @@ -4,10 +4,12 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Options; using Serilog; using Serilog.Sinks.Loki; using Serilog.Sinks.Loki.Labels; @@ -66,6 +68,14 @@ public static void AddShared(IServiceCollection services, IConfiguration config) .AddDefaultTokenProviders(); services.AddTransient(); + services.AddTransient(); + services.AddTransient( + sp => new EmailHelperRateLimitingDecorator( + sp.GetService(), + sp.GetService(), + sp.GetRequiredService>()) + ); + services.AddMemoryCache(); if (string.IsNullOrEmpty(config.GetValue("Email:Host"))) { // Dummy emails. @@ -123,4 +133,4 @@ private sealed class LokiConfigurationData public string Username { get; set; } public string Password { get; set; } } -} \ No newline at end of file +} diff --git a/SS14.Auth/Controllers/AuthApiController.cs b/SS14.Auth/Controllers/AuthApiController.cs index f079d39..e036bc0 100644 --- a/SS14.Auth/Controllers/AuthApiController.cs +++ b/SS14.Auth/Controllers/AuthApiController.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Internal; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; using SS14.Auth.Shared.Sessions; @@ -25,7 +24,7 @@ namespace SS14.Auth.Controllers; public class AuthApiController : ControllerBase { private readonly SessionManager _sessionManager; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; private readonly ISystemClock _systemClock; private readonly IConfiguration _cfg; @@ -35,7 +34,7 @@ public class AuthApiController : ControllerBase private string WebBaseUrl => _cfg.GetValue("WebBaseUrl") ?? ""; public AuthApiController(SpaceUserManager userManager, SignInManager signInManager, - SessionManager sessionManager, IEmailSender emailSender, ISystemClock systemClock, IConfiguration cfg) + SessionManager sessionManager, IEmailHelper emailSender, ISystemClock systemClock, IConfiguration cfg) { _userManager = userManager; _signInManager = signInManager; @@ -180,7 +179,11 @@ public async Task ResetPassword(ResetPasswordRequest request) code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var callbackUrl = $"{WebBaseUrl}Identity/Account/ResetPassword?code={code}&launcher=true"; - await ModelShared.SendResetEmail(_emailSender, email, callbackUrl); + var result = await _emailSender.SendResetEmail(email, callbackUrl); + if (result.HasError) + { + return StatusCode(500, result.Error); + } return Ok(); } @@ -317,4 +320,4 @@ public enum RegisterResponseStatus { Registered, RegisteredNeedConfirmation -} \ No newline at end of file +} diff --git a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs index 37e35cc..8ea6a31 100644 --- a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs +++ b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers.Text; using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; @@ -6,8 +6,8 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; +using SS14.Auth.Shared.Emails; namespace SS14.Web.Areas.Admin.Pages.Users; @@ -47,7 +47,7 @@ public async Task OnPostAsync() var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); - var user = ModelShared.CreateNewUser(userName, $"reserved+{userName}@playss14.com", _systemClock); + var user = IEmailHelper.CreateNewUser(userName, $"reserved+{userName}@playss14.com", _systemClock); user.AdminLocked = true; user.AdminNotes = "Account reserved via admin panel. If unlocking, change email and password!"; user.EmailConfirmed = true; diff --git a/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs index e567821..b727e83 100644 --- a/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs +++ b/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Net.Mime; using System.Text; @@ -18,7 +18,7 @@ namespace SS14.Web.Areas.Admin.Pages.Users; public class ViewUser : PageModel { private readonly SpaceUserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; private readonly SessionManager _sessionManager; private readonly PatreonDataManager _patreonDataManager; private readonly ApplicationDbContext _dbContext; @@ -58,7 +58,7 @@ public class InputModel public ViewUser( SpaceUserManager userManager, - IEmailSender emailSender, + IEmailHelper emailSender, SessionManager sessionManager, PatreonDataManager patreonDataManager, ApplicationDbContext dbContext, @@ -197,8 +197,10 @@ public async Task OnPostResendConfirmationAsync(Guid id) try { - await ModelShared.SendConfirmEmail(_emailSender, SpaceUser.Email, confirmLink); - StatusMessage = "Email sent"; + var result = await _emailSender.SendConfirmEmail(SpaceUser.Email, confirmLink); + StatusMessage = result.HasError + ? result.Error + : "Email sent"; } catch (Exception e) { diff --git a/SS14.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs index 2244b31..cb9800d 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Text; -using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -9,7 +8,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; @@ -20,14 +18,14 @@ public class ExternalLoginModel : PageModel { private readonly SignInManager _signInManager; private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; private readonly ILogger _logger; public ExternalLoginModel( SignInManager signInManager, UserManager userManager, ILogger logger, - IEmailSender emailSender) + IEmailHelper emailSender) { _signInManager = signInManager; _userManager = userManager; @@ -139,7 +137,12 @@ public async Task OnPostConfirmationAsync(string returnUrl = null values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme); - await ModelShared.SendConfirmEmail(_emailSender, Input.Email, callbackUrl); + var confirmSendResult = await _emailSender.SendConfirmEmail(Input.Email, callbackUrl); + if (confirmSendResult.HasError) + { + ModelState.AddModelError(string.Empty, confirmSendResult.Error); + return Page(); + } // If account confirmation is required, we need to show the link if we don't have a real email sender if (_userManager.Options.SignIn.RequireConfirmedAccount) diff --git a/SS14.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs index 4b1984a..d3d6830 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs @@ -1,14 +1,13 @@ -using System.ComponentModel.DataAnnotations; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Threading.Tasks; namespace SS14.Web.Areas.Identity.Pages.Account; @@ -16,9 +15,9 @@ namespace SS14.Web.Areas.Identity.Pages.Account; public class ForgotPasswordModel : PageModel { private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; - public ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) + public ForgotPasswordModel(UserManager userManager, IEmailHelper emailSender) { _userManager = userManager; _emailSender = emailSender; @@ -55,11 +54,16 @@ public async Task OnPostAsync() values: new { area = "Identity", code }, protocol: Request.Scheme); - await ModelShared.SendResetEmail(_emailSender, Input.Email, callbackUrl); + var result = await _emailSender.SendResetEmail(Input.Email, callbackUrl); + if (result.HasError) + { + ModelState.AddModelError(string.Empty, result.Error); + return Page(); + } return RedirectToPage("./ForgotPasswordConfirmation"); } return Page(); } -} \ No newline at end of file +} diff --git a/SS14.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs index 6ebc87d..6d36450 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; @@ -16,13 +15,13 @@ public partial class EmailModel : PageModel { private readonly UserManager _userManager; private readonly SignInManager _signInManager; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; private readonly AccountLogManager _logManager; public EmailModel( UserManager userManager, SignInManager signInManager, - IEmailSender emailSender, + IEmailHelper emailSender, AccountLogManager logManager) { _userManager = userManager; @@ -101,7 +100,12 @@ public async Task OnPostChangeEmailAsync() pageHandler: null, values: new { userId = userId, email = Input.NewEmail, code = code }, protocol: Request.Scheme); - await ModelShared.SendConfirmEmail(_emailSender, Input.NewEmail, callbackUrl); + var result = await _emailSender.SendConfirmEmail(Input.NewEmail, callbackUrl); + if (result.HasError) + { + ModelState.AddModelError(string.Empty, result.Error); + return Page(); + } await _logManager.LogAndSave(user, new AccountLogEmailChangeRequested(email, Input.NewEmail)); StatusMessage = "Confirmation link to change email sent. Please check your email."; @@ -135,7 +139,12 @@ public async Task OnPostSendVerificationEmailAsync() pageHandler: null, values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme); - await ModelShared.SendConfirmEmail(_emailSender, email, callbackUrl); + var result = await _emailSender.SendConfirmEmail(email, callbackUrl); + if (result.HasError) + { + ModelState.AddModelError(string.Empty, result.Error); + return Page(); + } StatusMessage = "Verification email sent. Please check your email."; return RedirectToPage(); diff --git a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index bfe5e2f..5a666f1 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; using SS14.Web.HCaptcha; @@ -24,7 +23,7 @@ public class RegisterModel : PageModel private readonly SignInManager _signInManager; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; private readonly ISystemClock _systemClock; private readonly HCaptchaService _hCaptcha; @@ -32,7 +31,7 @@ public RegisterModel( UserManager userManager, SignInManager signInManager, ILogger logger, - IEmailSender emailSender, + IEmailHelper emailSender, ISystemClock systemClock, HCaptchaService hCaptcha) { @@ -101,7 +100,7 @@ public async Task OnPostAsync(string returnUrl = null) if (!await _hCaptcha.ValidateHCaptcha(HCaptchaResponse, ModelState)) return Page(); - var user = ModelShared.CreateNewUser(userName, email, _systemClock); + var user = IEmailHelper.CreateNewUser(userName, email, _systemClock); var result = await _userManager.CreateAsync(user, Input.Password); if (result.Succeeded) { @@ -109,7 +108,12 @@ public async Task OnPostAsync(string returnUrl = null) var confirmLink = await GenerateEmailConfirmLink(user, returnUrl); - await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink); + var sendConfirmResult = await _emailSender.SendConfirmEmail(email, confirmLink); + if (sendConfirmResult.HasError) + { + ModelState.AddModelError(string.Empty, sendConfirmResult.Error); + return Page(); + } if (_userManager.Options.SignIn.RequireConfirmedAccount) { @@ -150,4 +154,4 @@ public async Task GenerateEmailConfirmLink( protocol: Request.Scheme); return callbackUrl; } -} \ No newline at end of file +} diff --git a/SS14.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs index 37c201a..b3573aa 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; -using SS14.Auth.Shared; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; using SS14.Web.HCaptcha; @@ -17,12 +16,12 @@ namespace SS14.Web.Areas.Identity.Pages.Account; public class ResendEmailConfirmationModel : PageModel { private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailHelper _emailSender; private readonly HCaptchaService _hCaptcha; public ResendEmailConfirmationModel( UserManager userManager, - IEmailSender emailSender, + IEmailHelper emailSender, HCaptchaService hCaptcha) { _userManager = userManager; @@ -75,9 +74,14 @@ public async Task OnPostAsync() values: new { userId = userId, code = code }, protocol: Request.Scheme); - await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink); + var result = await _emailSender.SendConfirmEmail(email, confirmLink); + if (result.HasError) + { + ModelState.AddModelError(string.Empty, result.Error); + return Page(); + } ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); return Page(); } -} \ No newline at end of file +} From ba0e4a89bb1a9f6c76f0c5d5de530f9e8c90f45e Mon Sep 17 00:00:00 2001 From: Fildrance Date: Thu, 18 Jun 2026 18:23:51 +0300 Subject: [PATCH 2/6] feat: changed global rate limit for emails to use Polly rate limiter --- SS14.Auth.Shared/Emails/DummyEmailSender.cs | 8 ++-- SS14.Auth.Shared/Emails/EmailHelper.cs | 6 +-- SS14.Auth.Shared/Emails/EmailSender.cs | 47 ++++++++++++++------- SS14.Auth.Shared/Emails/IEmailSender.cs | 4 +- SS14.Auth.Shared/Emails/SmtpEmailSender.cs | 5 ++- SS14.Auth.Shared/LimitOptions.cs | 22 +++++++++- SS14.Auth.Shared/StartupHelpers.cs | 2 +- SS14.Auth/Controllers/AuthApiController.cs | 2 +- 8 files changed, 64 insertions(+), 32 deletions(-) diff --git a/SS14.Auth.Shared/Emails/DummyEmailSender.cs b/SS14.Auth.Shared/Emails/DummyEmailSender.cs index ff5855a..6d09df7 100644 --- a/SS14.Auth.Shared/Emails/DummyEmailSender.cs +++ b/SS14.Auth.Shared/Emails/DummyEmailSender.cs @@ -6,14 +6,14 @@ namespace SS14.Auth.Shared.Emails; public class DummyEmailSender : IRawEmailSender { - public Task SendEmailAsync(string email, string subject, string htmlMessage) + public ValueTask 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()); } -} \ No newline at end of file +} diff --git a/SS14.Auth.Shared/Emails/EmailHelper.cs b/SS14.Auth.Shared/Emails/EmailHelper.cs index e1acf7b..c766d40 100644 --- a/SS14.Auth.Shared/Emails/EmailHelper.cs +++ b/SS14.Auth.Shared/Emails/EmailHelper.cs @@ -8,24 +8,22 @@ public class EmailHelper(IEmailSender sender) : IEmailHelper /// public async ValueTask SendConfirmEmail(string address, string confirmLink) { - await sender.SendEmailAsync( + return await sender.SendEmailAsync( address, "Confirm your Space Station 14 account", $"Please confirm your account by clicking here." + $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(confirmLink)}

"); - return EmailSendingResult.Success(); } /// public async ValueTask SendResetEmail(string address, string callbackUrl) { - await sender.SendEmailAsync( + return await sender.SendEmailAsync( address, "Reset Password", "A password reset has been requested for your account.
" + $"If you did indeed request this, click here to reset your password.
" + "If you did not request this, simply ignore this email." + $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)} _limits; + private readonly LimitOptions _limits; private readonly ILogger _logger; + private readonly ResiliencePipeline _pipeline; - public EmailSender(IRawEmailSender rawEmailSender, MutexDatabase mutex, IOptions limits, ILogger logger) + public EmailSender(IRawEmailSender rawEmailSender, IOptions limits, ILogger logger) { _rawEmailSender = rawEmailSender; - _mutex = mutex; - _limits = limits; + _limits = limits.Value; _logger = logger; + + _pipeline = new ResiliencePipelineBuilder() + .AddRateLimiter( + new SlidingWindowRateLimiter( + new SlidingWindowRateLimiterOptions + { + PermitLimit = _limits.GlobalMaxEmail, + Window = _limits.GlobalEmailWindow, + SegmentsPerWindow = 4 + }) + ) + .Build(); } - public async Task SendEmailAsync(string email, string subject, string htmlMessage) + public async ValueTask SendEmailAsync(string email, string subject, string htmlMessage) { - var count = _mutex.IncCount("Email"); - - if (count >= _limits.Value.MaxEmailsPerHour) + try { - _logger.LogCritical("HIT EMAIL LIMIT, no more emails will be sent for the rest of the hour!!!"); - return; + await _pipeline.ExecuteAsync(context => _rawEmailSender.SendEmailAsync(email, subject, htmlMessage)); } - - await _rawEmailSender.SendEmailAsync(email, subject, htmlMessage); + catch (RateLimiterRejectedException) + { + _logger.LogCritical("HIT EMAIL LIMIT!!! Current settings are window: {window}, max per window:{maxEmail}", _limits.GlobalEmailWindow, _limits.GlobalMaxEmail); + return EmailSendingResult.Failure("Rate limit for emails was reached, please try again later."); + } + + return EmailSendingResult.Success(); } -} \ No newline at end of file +} diff --git a/SS14.Auth.Shared/Emails/IEmailSender.cs b/SS14.Auth.Shared/Emails/IEmailSender.cs index 207ee2b..3800df3 100644 --- a/SS14.Auth.Shared/Emails/IEmailSender.cs +++ b/SS14.Auth.Shared/Emails/IEmailSender.cs @@ -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); -} \ No newline at end of file + ValueTask SendEmailAsync(string email, string subject, string htmlMessage); +} diff --git a/SS14.Auth.Shared/Emails/SmtpEmailSender.cs b/SS14.Auth.Shared/Emails/SmtpEmailSender.cs index e8a5a23..d2ef1d0 100644 --- a/SS14.Auth.Shared/Emails/SmtpEmailSender.cs +++ b/SS14.Auth.Shared/Emails/SmtpEmailSender.cs @@ -14,7 +14,7 @@ public SmtpEmailSender(IOptions options) _options = options.Value; } - public async Task SendEmailAsync(string email, string subject, string htmlMessage) + public async ValueTask SendEmailAsync(string email, string subject, string htmlMessage) { using var client = new SmtpClient(); await client.ConnectAsync(_options.Host, _options.Port); @@ -32,5 +32,6 @@ public async Task SendEmailAsync(string email, string subject, string htmlMessag }; await client.SendAsync(msg); + return EmailSendingResult.Success(); } -} \ No newline at end of file +} diff --git a/SS14.Auth.Shared/LimitOptions.cs b/SS14.Auth.Shared/LimitOptions.cs index a87d8ec..9352ac0 100644 --- a/SS14.Auth.Shared/LimitOptions.cs +++ b/SS14.Auth.Shared/LimitOptions.cs @@ -2,11 +2,29 @@ namespace SS14.Auth.Shared; +///

+/// Options for rate limiting email sending. +/// public sealed class LimitOptions { - public int MaxEmailsPerHour { get; set; } = 5000; - + /// + /// Time window for refreshing per-user email sending limits. + /// public TimeSpan RefreshWindowPerUser { get; set; } = new(0, 0, 10); + /// + /// Maximum amount of emails that can be sent to a single user in the time window. + /// public int MaxEmailsInWindowPerUser { get; set; } = 10; + + + /// + /// Time window for global email sending limits. + /// + public TimeSpan GlobalEmailWindow { get; set; } = new(0, 1, 0); + + /// + /// Maximum amount of emails that can be sent globally in the time window. + /// + public int GlobalMaxEmail { get; set; } = 5000; } diff --git a/SS14.Auth.Shared/StartupHelpers.cs b/SS14.Auth.Shared/StartupHelpers.cs index 305f553..152d7e5 100644 --- a/SS14.Auth.Shared/StartupHelpers.cs +++ b/SS14.Auth.Shared/StartupHelpers.cs @@ -67,7 +67,7 @@ public static void AddShared(IServiceCollection services, IConfiguration config) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddTransient( sp => new EmailHelperRateLimitingDecorator( diff --git a/SS14.Auth/Controllers/AuthApiController.cs b/SS14.Auth/Controllers/AuthApiController.cs index e036bc0..04f5037 100644 --- a/SS14.Auth/Controllers/AuthApiController.cs +++ b/SS14.Auth/Controllers/AuthApiController.cs @@ -182,7 +182,7 @@ public async Task ResetPassword(ResetPasswordRequest request) var result = await _emailSender.SendResetEmail(email, callbackUrl); if (result.HasError) { - return StatusCode(500, result.Error); + return StatusCode(429, result.Error); } return Ok(); From cc1f546ae5b2034927a38480763d060760f8ea68 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Thu, 18 Jun 2026 20:41:46 +0300 Subject: [PATCH 3/6] refactor: xml-doc, code simplification, logging for when rate limit was hit --- .../EmailHelperRateLimitingDecorator.cs | 35 ++++++++++--------- SS14.Auth.Shared/Emails/EmailSender.cs | 28 ++++++++++----- SS14.Auth.Shared/Emails/EmailSendingResult.cs | 3 ++ SS14.Auth.Shared/LimitOptions.cs | 2 +- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs b/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs index b4e04ab..d88de31 100644 --- a/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs +++ b/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs @@ -4,6 +4,7 @@ using System; using System.Threading.RateLimiting; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace SS14.Auth.Shared.Emails; @@ -13,40 +14,34 @@ namespace SS14.Auth.Shared.Emails; /// 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. /// -public class EmailHelperRateLimitingDecorator(IEmailHelper inner, IMemoryCache cache, IOptions limitOptions) : IEmailHelper +public class EmailHelperRateLimitingDecorator(IEmailHelper inner, IMemoryCache cache, IOptions limitOptions, ILogger logger) : IEmailHelper { /// public async ValueTask SendConfirmEmail(string address, string confirmLink) { var pipeline = GetOrCreatePipeline(address, "confirm"); - EmailSendingResult value; try { - value = await pipeline.ExecuteAsync(t => inner.SendConfirmEmail(address, confirmLink)); + 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."); } - - return value; } /// public async ValueTask SendResetEmail(string address, string callbackUrl) { var pipeline = GetOrCreatePipeline(address, "reset"); - EmailSendingResult value; try { - value = await pipeline.ExecuteAsync(t => inner.SendResetEmail(address, callbackUrl)); + 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."); } - - return value; } private ResiliencePipeline GetOrCreatePipeline(string address, string purpose) @@ -59,17 +54,25 @@ private ResiliencePipeline GetOrCreatePipeline(string address, string purpose) 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 SlidingWindowRateLimiter( - new SlidingWindowRateLimiterOptions + new RateLimiterStrategyOptions + { + RateLimiter = _ => rateLimiter.AcquireAsync(), + OnRejected = _ => { - PermitLimit = options.MaxEmailsInWindowPerUser, - Window = options.RefreshWindowPerUser, - SegmentsPerWindow = 4 - }) + logger.LogWarning("Rate limit rejected for user: {Address}, purpose: {Purpose}", address, purpose); + return ValueTask.CompletedTask; + } + } ).Build(); })!; } } - diff --git a/SS14.Auth.Shared/Emails/EmailSender.cs b/SS14.Auth.Shared/Emails/EmailSender.cs index 32fca6e..79cff58 100644 --- a/SS14.Auth.Shared/Emails/EmailSender.cs +++ b/SS14.Auth.Shared/Emails/EmailSender.cs @@ -20,15 +20,27 @@ public EmailSender(IRawEmailSender rawEmailSender, IOptions 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 SlidingWindowRateLimiter( - new SlidingWindowRateLimiterOptions + new RateLimiterStrategyOptions + { + RateLimiter = _ => rateLimiter.AcquireAsync(), + OnRejected = _ => { - PermitLimit = _limits.GlobalMaxEmail, - Window = _limits.GlobalEmailWindow, - SegmentsPerWindow = 4 - }) + _logger.LogCritical( + "HIT EMAIL LIMIT!!! Current settings are window: {window}, max per window:{maxEmail}", + _limits.GlobalEmailWindow, + _limits.GlobalMaxEmail); + return ValueTask.CompletedTask; + }, + } ) .Build(); } @@ -38,13 +50,11 @@ public async ValueTask SendEmailAsync(string email, string s try { await _pipeline.ExecuteAsync(context => _rawEmailSender.SendEmailAsync(email, subject, htmlMessage)); + return EmailSendingResult.Success(); } catch (RateLimiterRejectedException) { - _logger.LogCritical("HIT EMAIL LIMIT!!! Current settings are window: {window}, max per window:{maxEmail}", _limits.GlobalEmailWindow, _limits.GlobalMaxEmail); return EmailSendingResult.Failure("Rate limit for emails was reached, please try again later."); } - - return EmailSendingResult.Success(); } } diff --git a/SS14.Auth.Shared/Emails/EmailSendingResult.cs b/SS14.Auth.Shared/Emails/EmailSendingResult.cs index be887c5..9782938 100644 --- a/SS14.Auth.Shared/Emails/EmailSendingResult.cs +++ b/SS14.Auth.Shared/Emails/EmailSendingResult.cs @@ -3,6 +3,9 @@ namespace SS14.Auth.Shared.Emails; +/// +/// Wrapper for results of email sending. Can contain error message, success-wrapper is resued. +/// public class EmailSendingResult { private static readonly EmailSendingResult SuccessSingleton = new(null); diff --git a/SS14.Auth.Shared/LimitOptions.cs b/SS14.Auth.Shared/LimitOptions.cs index 9352ac0..b57ef1a 100644 --- a/SS14.Auth.Shared/LimitOptions.cs +++ b/SS14.Auth.Shared/LimitOptions.cs @@ -19,7 +19,7 @@ public sealed class LimitOptions /// - /// Time window for global email sending limits. + /// Time window for refreshing global email sending limits. /// public TimeSpan GlobalEmailWindow { get; set; } = new(0, 1, 0); From 8f3ebf4eac3379a0f1a69835c8ddf2cdc09c47d0 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Thu, 18 Jun 2026 21:34:21 +0300 Subject: [PATCH 4/6] fix: Admin\ViewUser should now properly handle fail-to-send confirm email error --- SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs index b727e83..a806a1a 100644 --- a/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs +++ b/SS14.Web/Areas/Admin/Pages/Users/ViewUser.cshtml.cs @@ -199,7 +199,7 @@ public async Task OnPostResendConfirmationAsync(Guid id) { var result = await _emailSender.SendConfirmEmail(SpaceUser.Email, confirmLink); StatusMessage = result.HasError - ? result.Error + ? "Error: " + result.Error : "Email sent"; } catch (Exception e) From a3e682683f9f444ab860695d8519f4b9e4c70407 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Thu, 18 Jun 2026 21:41:31 +0300 Subject: [PATCH 5/6] refactor: add proper error message for failure to send confirmation email on register. --- SS14.Auth.Shared/StartupHelpers.cs | 8 +++++--- SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/SS14.Auth.Shared/StartupHelpers.cs b/SS14.Auth.Shared/StartupHelpers.cs index 152d7e5..9c9c779 100644 --- a/SS14.Auth.Shared/StartupHelpers.cs +++ b/SS14.Auth.Shared/StartupHelpers.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Serilog; using Serilog.Sinks.Loki; @@ -71,9 +72,10 @@ public static void AddShared(IServiceCollection services, IConfiguration config) services.AddTransient(); services.AddTransient( sp => new EmailHelperRateLimitingDecorator( - sp.GetService(), - sp.GetService(), - sp.GetRequiredService>()) + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>()) ); services.AddMemoryCache(); if (string.IsNullOrEmpty(config.GetValue("Email:Host"))) diff --git a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 5a666f1..6ffe169 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -111,7 +111,12 @@ public async Task OnPostAsync(string returnUrl = null) var sendConfirmResult = await _emailSender.SendConfirmEmail(email, confirmLink); if (sendConfirmResult.HasError) { - ModelState.AddModelError(string.Empty, sendConfirmResult.Error); + ModelState.AddModelError( + string.Empty, + "Account was created successfully, but error occurred " + + $"while sending confirmation email (Error: {sendConfirmResult.Error}). " + + "Please retry sending confirmation email using `Resend email confirmation` option on log in page." + ); return Page(); } From b0bad0d85afff1a473a3576a1cf7d8ff84d48aef Mon Sep 17 00:00:00 2001 From: Fildrance Date: Thu, 18 Jun 2026 21:57:41 +0300 Subject: [PATCH 6/6] refactor: removed 'purpose' part of rate-limiting cache key --- SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs b/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs index d88de31..a90e82d 100644 --- a/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs +++ b/SS14.Auth.Shared/Emails/EmailHelperRateLimitingDecorator.cs @@ -46,7 +46,7 @@ public async ValueTask SendResetEmail(string address, string private ResiliencePipeline GetOrCreatePipeline(string address, string purpose) { - var cacheKey = $"email_pipeline_{purpose}_{address}"; + var cacheKey = $"email_pipeline_{address}"; return cache.GetOrCreate(cacheKey, entry => { @@ -68,7 +68,7 @@ private ResiliencePipeline GetOrCreatePipeline(string address, string purpose) RateLimiter = _ => rateLimiter.AcquireAsync(), OnRejected = _ => { - logger.LogWarning("Rate limit rejected for user: {Address}, purpose: {Purpose}", address, purpose); + logger.LogWarning("Rate limit rejected for user: {Address}, purpose: {Purpose}.", address, purpose); return ValueTask.CompletedTask; } }