From 74e0009cc775e6877fed3c01c5e807fd11a35e02 Mon Sep 17 00:00:00 2001 From: Rinary Date: Mon, 22 Jun 2026 12:49:24 +0300 Subject: [PATCH 1/5] init --- SS14.Auth.Shared/ModelShared.cs | 17 ++- SS14.Auth/Controllers/AuthApiController.cs | 132 +++++++++--------- SS14.Auth/Startup.cs | 62 +++++++- .../Pages/Users/CreateReserved.cshtml.cs | 4 +- .../Identity/Pages/Account/Register.cshtml.cs | 4 +- 5 files changed, 147 insertions(+), 72 deletions(-) diff --git a/SS14.Auth.Shared/ModelShared.cs b/SS14.Auth.Shared/ModelShared.cs index 155367e..09d9eb8 100644 --- a/SS14.Auth.Shared/ModelShared.cs +++ b/SS14.Auth.Shared/ModelShared.cs @@ -1,6 +1,6 @@ +using System; using System.Text.Encodings.Web; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; @@ -15,6 +15,17 @@ await sender.SendEmailAsync(address, "Confirm your Space Station 14 account", $"\n

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

"); } + public static async Task SendAccountExistsEmail(IEmailSender sender, string address, string loginUrl) + { + await sender.SendEmailAsync(address, "Registration attempt on your Space Station 14 account", + "Someone just tried to register a new Space Station 14 account using this email address, " + + "but an account already exists for it. No new account was created." + + $"\n

If this was you: you already have an account - just log in, " + + "or reset your password from the login page if you've forgotten it.

" + + "\n

If this wasn't you, you don't need to do anything. Your account is unaffected.

" + + $"\n

If the link above is not working, go to {HtmlEncoder.Default.Encode(loginUrl)}

"); + } + public static async Task SendResetEmail(IEmailSender emailSender, string email, string callbackUrl) { await emailSender.SendEmailAsync( @@ -25,8 +36,8 @@ await emailSender.SendEmailAsync( $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)} _signInManager; + private readonly Lazy _dummyUser = new(() => + { + var u = new SpaceUser(); + u.PasswordHash = new PasswordHasher().HashPassword(u, "timing-equalizer"); + return u; + }); + private string WebBaseUrl => _cfg.GetValue("WebBaseUrl") ?? ""; + private const string DuplicateEmailCode = "DuplicateEmail"; + public AuthApiController(SpaceUserManager userManager, SignInManager signInManager, - SessionManager sessionManager, IEmailSender emailSender, ISystemClock systemClock, IConfiguration cfg) + SessionManager sessionManager, IEmailSender emailSender, TimeProvider systemClock, IConfiguration cfg) { _userManager = userManager; _signInManager = signInManager; @@ -45,17 +55,17 @@ public AuthApiController(SpaceUserManager userManager, SignInManager _cfg = cfg; } + [EnableRateLimiting("authenticate")] [HttpPost("authenticate")] public async Task Authenticate(AuthenticateRequest request) { // Password may never be null, and only either username OR userID can be used for login, not both. if (!(request.Username == null ^ request.UserId == null)) - { - return BadRequest(); - } - - // Console.WriteLine(Request.Headers["SS14-Launcher-Fingerprint"]); - // Console.WriteLine(Request.Headers["User-Agent"]); + return BadRequest(new + { + Error = "invalid_request", + Message = "Exactly one of Username or UserId must be provided." + }); SpaceUser? user; if (request.Username != null) @@ -65,12 +75,13 @@ public async Task Authenticate(AuthenticateRequest request) else { Debug.Assert(request.UserId != null); - user = await _userManager.FindByIdAsync(request.UserId!.Value.ToString()); } if (user == null) { + await _signInManager.CheckPasswordSignInAsync(_dummyUser.Value, request.Password, false); + return Unauthorized(new AuthenticateDenyResponse( new[] { "Invalid login credentials." }, AuthenticateDenyResponseCode.InvalidCredentials)); @@ -113,42 +124,57 @@ public async Task Authenticate(AuthenticateRequest request) new[] { "" }, AuthenticateDenyResponseCode.TfaRequired)); } - + var verify = await _userManager.VerifyTwoFactorTokenAsync( user, _userManager.Options.Tokens.AuthenticatorTokenProvider, request.TfaCode); - + if (!verify) { return Unauthorized(new AuthenticateDenyResponse( new[] { "" }, AuthenticateDenyResponseCode.TfaInvalid)); } - + // 2FA passed, we're good. } - + var (token, expireTime) = await _sessionManager.RegisterNewSession(user, SessionManager.DefaultExpireTime); return Ok(new AuthenticateResponse(token.AsBase64, user.UserName!, user.Id, expireTime)); } - // Launcher registration disabled due to spam risk. - /* + [EnableRateLimiting("registration")] [HttpPost("register")] public async Task Register(RegisterRequest request) { var userName = request.Username.Trim(); var email = request.Email.Trim(); - var user = ModelShared.CreateNewUser(userName, email, _systemClock); + var user = ModelShared.CreateNewUser(userName, email, _systemClock.GetUtcNow()); var result = await _userManager.CreateAsync(user, request.Password); + var successStatus = _userManager.Options.SignIn.RequireConfirmedEmail + ? RegisterResponseStatus.RegisteredNeedConfirmation + : RegisterResponseStatus.Registered; + if (!result.Succeeded) { - var errors = result.Errors.Select(p => p.Description).ToArray(); + var errors = result.Errors + .Where(e => e.Code != DuplicateEmailCode) + .Select(e => e.Description) + .ToArray(); + + if (errors.Length == 0) + { + var loginUrl = $"{WebBaseUrl}Identity/Account/Login"; + await ModelShared.SendAccountExistsEmail(_emailSender, email, loginUrl); + + return Ok(new RegisterResponse(successStatus)); + } + return UnprocessableEntity(new RegisterResponseError(errors)); } @@ -156,14 +182,10 @@ public async Task Register(RegisterRequest request) await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink); - var status = _userManager.Options.SignIn.RequireConfirmedAccount - ? RegisterResponseStatus.RegisteredNeedConfirmation - : RegisterResponseStatus.Registered; - - return Ok(new RegisterResponse(status)); + return Ok(new RegisterResponse(successStatus)); } - */ + [EnableRateLimiting("reset-password")] [HttpPost("resetPassword")] public async Task ResetPassword(ResetPasswordRequest request) { @@ -185,8 +207,7 @@ public async Task ResetPassword(ResetPasswordRequest request) return Ok(); } - // Launcher resend confirmation disabled due to spam risk. - /* + [EnableRateLimiting("resend-confirmation")] [HttpPost("resendConfirmation")] public async Task ResendConfirmation(ResendConfirmationRequest request) { @@ -194,18 +215,14 @@ public async Task ResendConfirmation(ResendConfirmationRequest re var user = await _userManager.FindByEmailAsync(email); - if (user == null) + if (user != null && !await _userManager.IsEmailConfirmedAsync(user)) { - return Ok(); + var confirmLink = await GenerateEmailConfirmLink(user); + await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink); } - var confirmLink = await GenerateEmailConfirmLink(user); - - await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink); - return Ok(); } - */ [Authorize(AuthenticationSchemes = "SS14Auth")] [HttpGet("ping")] @@ -281,40 +298,27 @@ public enum AuthenticateDenyResponseCode // @formatter:on } -public sealed record RegisterRequest(string Username, string Email, string Password) -{ -} +public sealed record RegisterRequest( + [Required, StringLength(32, MinimumLength = 3)] string Username, + [Required, EmailAddress] string Email, + [Required, StringLength(128, MinimumLength = 8)] string Password); -public sealed record ResetPasswordRequest(string Email) -{ -} +public sealed record ResetPasswordRequest([Required, EmailAddress] string Email); -public sealed record ResendConfirmationRequest(string Email) -{ -} +public sealed record ResendConfirmationRequest([Required, EmailAddress] string Email); -public sealed record RegisterResponse(RegisterResponseStatus Status) -{ -} +public sealed record RegisterResponse(RegisterResponseStatus Status); -public sealed record RegisterResponseError(string[] Errors) -{ -} +public sealed record RegisterResponseError(string[] Errors); -public sealed record LogoutRequest(string Token) -{ -} +public sealed record LogoutRequest(string Token); -public sealed record RefreshRequest(string Token) -{ -} +public sealed record RefreshRequest(string Token); -public sealed record RefreshResponse(DateTimeOffset ExpireTime, string NewToken) -{ -} +public sealed record RefreshResponse(DateTimeOffset ExpireTime, string NewToken); public enum RegisterResponseStatus { Registered, RegisteredNeedConfirmation -} \ No newline at end of file +} diff --git a/SS14.Auth/Startup.cs b/SS14.Auth/Startup.cs index 817196e..1a03b9b 100644 --- a/SS14.Auth/Startup.cs +++ b/SS14.Auth/Startup.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -11,6 +12,8 @@ using SS14.Auth.Shared; using SS14.Auth.Shared.Auth; using SS14.WebEverythingShared; +using System; +using System.Threading.RateLimiting; namespace SS14.Auth; @@ -36,6 +39,61 @@ public void ConfigureServices(IServiceCollection services) .Build()); }); + services.AddRateLimiter(options => + { + options.AddPolicy("registration", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(15), + QueueLimit = 0, + }); + }); + + options.AddPolicy("resend-confirmation", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = 2, + Window = TimeSpan.FromHours(1), + QueueLimit = 0, + }); + }); + + options.AddPolicy("authenticate", httpContext => { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + }); + }); + + options.AddPolicy("reset-password", httpContext => { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = 3, + Window = TimeSpan.FromMinutes(15), + QueueLimit = 0 + }); + }); + + options.OnRejected = async (ctx, token) => + { + ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await ctx.HttpContext.Response.WriteAsJsonAsync(new { Error = "rate_limited" }, token); + }; + }); + services.AddAuthentication() .AddScheme("SS14Auth", _ => {}); @@ -85,6 +143,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseRouting(); + app.UseRateLimiter(); + app.UseHttpMetrics(); app.UseAuthorization(); @@ -95,4 +155,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapMetrics(); }); } -} \ 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..5a9f12f 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; @@ -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 = ModelShared.CreateNewUser(userName, $"reserved+{userName}@playss14.com", _systemClock.UtcNow); 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/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index bfe5e2f..32a2f0a 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -101,7 +101,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 = ModelShared.CreateNewUser(userName, email, _systemClock.UtcNow); var result = await _userManager.CreateAsync(user, Input.Password); if (result.Succeeded) { @@ -150,4 +150,4 @@ public async Task GenerateEmailConfirmLink( protocol: Request.Scheme); return callbackUrl; } -} \ No newline at end of file +} From 25441a9339839b9af87a20334dc44885caff63e3 Mon Sep 17 00:00:00 2001 From: Rinary Date: Mon, 22 Jun 2026 13:38:54 +0300 Subject: [PATCH 2/5] same limits --- SS14.Auth/Controllers/AuthApiController.cs | 2 +- SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/SS14.Auth/Controllers/AuthApiController.cs b/SS14.Auth/Controllers/AuthApiController.cs index ebbdce6..4ff9122 100644 --- a/SS14.Auth/Controllers/AuthApiController.cs +++ b/SS14.Auth/Controllers/AuthApiController.cs @@ -301,7 +301,7 @@ public enum AuthenticateDenyResponseCode public sealed record RegisterRequest( [Required, StringLength(32, MinimumLength = 3)] string Username, [Required, EmailAddress] string Email, - [Required, StringLength(128, MinimumLength = 8)] string Password); + [Required, StringLength(100, MinimumLength = 6)] string Password); public sealed record ResetPasswordRequest([Required, EmailAddress] string Email); diff --git a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 32a2f0a..20cbcbd 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -56,6 +56,7 @@ public RegisterModel( public class InputModel { [Required] + [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 3)] [Display(Name = "Username")] public string Username { get; set; } From ce235783ad8013eda850fbf16943d68a7f33763e Mon Sep 17 00:00:00 2001 From: Rinary Date: Mon, 22 Jun 2026 14:19:43 +0300 Subject: [PATCH 3/5] return ISystemClock --- SS14.Auth.Shared/ModelShared.cs | 6 +++--- SS14.Auth/Controllers/AuthApiController.cs | 7 ++++--- SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs | 2 +- SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/SS14.Auth.Shared/ModelShared.cs b/SS14.Auth.Shared/ModelShared.cs index 09d9eb8..b1720fd 100644 --- a/SS14.Auth.Shared/ModelShared.cs +++ b/SS14.Auth.Shared/ModelShared.cs @@ -1,6 +1,6 @@ -using System; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; @@ -36,8 +36,8 @@ await emailSender.SendEmailAsync( $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)} signInManager, - SessionManager sessionManager, IEmailSender emailSender, TimeProvider systemClock, IConfiguration cfg) + SessionManager sessionManager, IEmailSender emailSender, ISystemClock systemClock, IConfiguration cfg) { _userManager = userManager; _signInManager = signInManager; @@ -153,7 +154,7 @@ public async Task Register(RegisterRequest request) var userName = request.Username.Trim(); var email = request.Email.Trim(); - var user = ModelShared.CreateNewUser(userName, email, _systemClock.GetUtcNow()); + var user = ModelShared.CreateNewUser(userName, email, _systemClock); var result = await _userManager.CreateAsync(user, request.Password); var successStatus = _userManager.Options.SignIn.RequireConfirmedEmail diff --git a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs index 5a9f12f..0cac70f 100644 --- a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs +++ b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs @@ -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.UtcNow); + var user = ModelShared.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/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 20cbcbd..15c5523 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -102,7 +102,7 @@ public async Task OnPostAsync(string returnUrl = null) if (!await _hCaptcha.ValidateHCaptcha(HCaptchaResponse, ModelState)) return Page(); - var user = ModelShared.CreateNewUser(userName, email, _systemClock.UtcNow); + var user = ModelShared.CreateNewUser(userName, email, _systemClock); var result = await _userManager.CreateAsync(user, Input.Password); if (result.Succeeded) { From 52c4eaf10766089432b377207048d9cf3742b045 Mon Sep 17 00:00:00 2001 From: Rinary Date: Mon, 22 Jun 2026 14:22:53 +0300 Subject: [PATCH 4/5] Revert "return ISystemClock" This reverts commit 5c901d8bd6b9352546af336923fe78a8464cae8c. --- SS14.Auth.Shared/ModelShared.cs | 6 +++--- SS14.Auth/Controllers/AuthApiController.cs | 7 +++---- SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs | 2 +- SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/SS14.Auth.Shared/ModelShared.cs b/SS14.Auth.Shared/ModelShared.cs index b1720fd..09d9eb8 100644 --- a/SS14.Auth.Shared/ModelShared.cs +++ b/SS14.Auth.Shared/ModelShared.cs @@ -1,6 +1,6 @@ +using System; using System.Text.Encodings.Web; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; using SS14.Auth.Shared.Data; using SS14.Auth.Shared.Emails; @@ -36,8 +36,8 @@ await emailSender.SendEmailAsync( $"\n

If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)} signInManager, - SessionManager sessionManager, IEmailSender emailSender, ISystemClock systemClock, IConfiguration cfg) + SessionManager sessionManager, IEmailSender emailSender, TimeProvider systemClock, IConfiguration cfg) { _userManager = userManager; _signInManager = signInManager; @@ -154,7 +153,7 @@ public async Task Register(RegisterRequest request) var userName = request.Username.Trim(); var email = request.Email.Trim(); - var user = ModelShared.CreateNewUser(userName, email, _systemClock); + var user = ModelShared.CreateNewUser(userName, email, _systemClock.GetUtcNow()); var result = await _userManager.CreateAsync(user, request.Password); var successStatus = _userManager.Options.SignIn.RequireConfirmedEmail diff --git a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs index 0cac70f..5a9f12f 100644 --- a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs +++ b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs @@ -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 = ModelShared.CreateNewUser(userName, $"reserved+{userName}@playss14.com", _systemClock.UtcNow); 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/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 15c5523..20cbcbd 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -102,7 +102,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 = ModelShared.CreateNewUser(userName, email, _systemClock.UtcNow); var result = await _userManager.CreateAsync(user, Input.Password); if (result.Succeeded) { From 91a1895e55558db3885d898ca028f87eb811a3f0 Mon Sep 17 00:00:00 2001 From: Rinary Date: Mon, 22 Jun 2026 14:28:19 +0300 Subject: [PATCH 5/5] br --- SS14.Auth.Shared/ModelShared.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SS14.Auth.Shared/ModelShared.cs b/SS14.Auth.Shared/ModelShared.cs index 09d9eb8..f16233a 100644 --- a/SS14.Auth.Shared/ModelShared.cs +++ b/SS14.Auth.Shared/ModelShared.cs @@ -19,7 +19,7 @@ public static async Task SendAccountExistsEmail(IEmailSender sender, string addr { await sender.SendEmailAsync(address, "Registration attempt on your Space Station 14 account", "Someone just tried to register a new Space Station 14 account using this email address, " + - "but an account already exists for it. No new account was created." + + "but an account already exists for it. No new account was created.
" + $"\n

If this was you: you already have an account - just log in, " + "or reset your password from the login page if you've forgotten it.

" + "\n

If this wasn't you, you don't need to do anything. Your account is unaffected.

" + @@ -33,7 +33,7 @@ await emailSender.SendEmailAsync( "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)}If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)}

"); } public static SpaceUser CreateNewUser(string userName, string email, DateTimeOffset timeOffset)