Merge pull request #192 from Sea-Haven-Industries/fix/ab/sh-403-reset-code-hardening
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions

fix(auth): stop reset-code guessing and email enumeration in Forgot Password
This commit is contained in:
Alexandre Brandizzi 2026-09-25 22:16:01 +00:00 • committed by GitHub
commit de8e283ee5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 5720 additions and 152 deletions

View file

@ -232,35 +232,45 @@ public class AuthenticationControllerTests
}
[Fact]
public async Task ForgetPassword_Found_ReturnsCheckEmailMessage()
public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.ForgetPassword("a@b.com", CancellationToken.None);
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
var unregistered = await controller.ForgetPassword(null, "x@y.com", CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Success ");
response.Message.Should().Be("Please check your email for code");
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
Json(unregistered.Should().BeOfType<OkObjectResult>().Subject.Value).Should().Be(Json(ok.Value));
service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET a@b.com"));
var logger = new Mock<ILogger<AuthenticationController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new AuthenticationController(service.Object, logger.Object);
var controller = NewController(service);
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
var result = await controller.ForgetPassword("x@y.com", CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Error");
response.Message.Should().Be("No such email is registered");
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Theory]
@ -269,11 +279,11 @@ public class AuthenticationControllerTests
public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage)
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
var controller = NewController(service);
var result = await controller.VerificationCode("123456", CancellationToken.None);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
if (matched)
{
@ -291,6 +301,33 @@ public class AuthenticationControllerTests
}
}
[Fact]
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service);
var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>()
.Which.Message.Should().Be("Code Not Matched");
service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail"));
var controller = NewController(service);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, null, null, CancellationToken.None);
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
}
[Fact]
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
{

View file

@ -6,6 +6,7 @@ using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
@ -25,7 +26,7 @@ public class AuthenticationServiceTests
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, TimeProvider.System);
}
private static JwtOptions JwtOptions => new()
@ -99,31 +100,36 @@ public class AuthenticationServiceTests
}
[Fact]
public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail()
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
string? stored = null, salt = null, body = null;
DateTime expires = default;
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, CancellationToken>((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; })
.Returns(Task.CompletedTask);
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
.Callback<string, string, string>((_, _, b) => body = b)
.ReturnsAsync(true);
var service = NewService(userData, forget, email, out _, out _);
var before = DateTime.UtcNow;
var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
found.Should().BeTrue();
forget.Verify(
f => f.ReplaceCodeAsync(
user.Email!,
user.Id,
It.Is<string>(code => code.Length == 6 && code.All(char.IsDigit)),
It.IsAny<CancellationToken>()),
Times.Once);
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(b => b.Contains("Your Password Reset Code is:"))), Times.Once);
var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
code.Should().HaveLength(6);
stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$");
PasswordResetCodeSecrets.Matches(salt!, code, stored!).Should().BeTrue();
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode()
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
@ -132,40 +138,90 @@ public class AuthenticationServiceTests
var service = NewService(userData, forget, email, out _, out _);
var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny<CancellationToken>()), Times.Once);
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityTestHelpers.User(isDeleted: true));
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var service = NewService(userData, forget, email, out _, out _);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
found.Should().BeFalse();
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task VerifyCode_ForwardsDataServiceResult(bool exists)
[InlineData(null, "123456")]
[InlineData("", "123456")]
[InlineData(" ", "123456")]
[InlineData("a@b.com", null)]
[InlineData("a@b.com", "")]
public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code)
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
var result = await service.VerifyCodeAsync("abc", CancellationToken.None);
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
result.Should().Be(exists);
result.Should().BeFalse();
}
[Fact]
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset()
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
forget.Setup(f => f.GetByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ForgetPasswordCode?)null);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None);
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
result.Should().BeFalse();
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails()
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash("s", "123456"), FailedAttempts = 5 };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
result.Should().BeFalse();
forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny<CancellationToken>()), Times.Once);
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData("123456", "123456", true)]
[InlineData("123456", " 123456 ", true)]
[InlineData("123456", "123457", false)]
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
{
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, issued);
PasswordResetCodeSecrets.Matches(salt, candidate, hash).Should().Be(expected);
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
PasswordResetCodeSecrets.Matches("", issued, hash).Should().BeFalse();
PasswordResetCodeSecrets.Matches(salt, issued, "").Should().BeFalse();
}
}

View file

@ -9,6 +9,7 @@ using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -132,7 +133,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>());
Mock.Of<IEmailSender>(),
TimeProvider.System);
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
@ -169,10 +171,19 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var salt = PasswordResetCodeSecrets.NewSalt();
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
.ReturnsAsync(new ForgetPasswordCode
{
Id = 7,
Email = user.Email!,
UserId = user.Id,
CodeSalt = salt,
CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"),
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10)
});
forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = NewAuthenticationService(forget);
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
@ -180,6 +191,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
reset.Should().BeFalse();
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny<CancellationToken>()), Times.Once);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
@ -208,7 +220,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
Mock.Of<IEmailSender>(),
TimeProvider.System);
public async ValueTask DisposeAsync()
{

View file

@ -1,11 +1,15 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Runtime.CompilerServices;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
@ -108,30 +112,47 @@ namespace Api.SeaHavenIndustries.Controllers
#region Forget Password Area
public const string ForgetPasswordMessage =
"If that email belongs to an account, a reset code has been sent to it.";
// Email and code are read from the JSON body so they stay out of URLs and proxy
// access logs; the query-string form is still accepted for older clients.
[AllowAnonymous]
[HttpPost()]
[Route("ForgetPassword")]
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
{
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
if (found)
{
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
}
else
{
return BadRequest(new Response { Status = "Error", Message = "No such email is registered" });
}
}
//need email and code
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
public async Task<IActionResult> ForgetPassword(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
[FromQuery(Name = "Email")] string? email,
CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken);
}
catch (Exception ex)
{
// Same answer as success: a failure only a registered address can hit
// must not reveal that the address is registered.
LogResetFailure(ex);
}
return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage });
}
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
[EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)]
public async Task<IActionResult> VerificationCode(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body,
[FromQuery] string? email,
[FromQuery] string? code,
CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.VerifyCodeAsync(body?.Email ?? email, body?.Code ?? code, cancellationToken))
{
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
@ -143,14 +164,15 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
LogResetFailure(ex);
return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" });
}
}
// need email, password and code
[AllowAnonymous]
[HttpPost()]
[Route("ResetPassword")]
[EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)]
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
{
try
@ -166,11 +188,21 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
LogResetFailure(ex);
return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" });
}
}
// These endpoints answer failures exactly like a wrong code or an unknown email, so
// an error only a registered account can trigger reveals nothing. Exception
// messages here can echo the email or code, so only the type is logged.
private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "")
{
_logger.LogError(
"Password reset {Operation} failed with {ExceptionType}.",
operation,
exception.GetType().FullName);
}
#endregion
}
}

View file

@ -4,9 +4,20 @@ namespace Api.SeaHavenIndustries.DTOs
{
public class ForgetPassword_Dto
{
public string Email { get; set; }
public string? Email { get; set; }
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
public string? Password { get; set; }
public string? Code { get; set; }
}
public class ForgetPasswordRequest_Dto
{
public string? Email { get; set; }
}
public class VerificationCode_Dto
{
public string? Email { get; set; }
public string? Code { get; set; }
}
}

View file

@ -0,0 +1,84 @@
using System.Net;
using System.Threading.RateLimiting;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
namespace Api.SeaHavenIndustries.Infrastructure;
/// <summary>
/// Per-client limits on the anonymous password reset endpoints, plus the
/// forwarded-header trust that makes "per client" mean the caller and not the proxy.
/// </summary>
public static class PasswordResetRateLimiting
{
public const string ForgetPasswordPolicy = "password-reset-request";
public const string VerificationCodePolicy = "password-reset-verify";
public const string ResetPasswordPolicy = "password-reset-confirm";
public const int PermitLimit = 10;
public static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
public const string RejectedMessage = "Too many requests. Please try again later.";
/// <summary>
/// The API runs on Elastic Beanstalk behind an application load balancer and the
/// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For
/// is read right to left through loopback and private (VPC) hops only; the first
/// public address is the client. Entries a caller writes further left are ignored.
/// </summary>
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
options.ForwardLimit = null;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16));
options.KnownProxies.Add(IPAddress.IPv6Loopback);
});
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(
new Response { Status = "Error", Message = RejectedMessage },
cancellationToken);
};
AddPerClientPolicy(options, ForgetPasswordPolicy);
AddPerClientPolicy(options, VerificationCodePolicy);
AddPerClientPolicy(options, ResetPasswordPolicy);
});
return services;
}
public static string ClientPartitionKey(HttpContext context)
{
var address = context.Connection.RemoteIpAddress;
if (address == null)
return "unknown";
return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString();
}
private static void AddPerClientPolicy(RateLimiterOptions options, string policyName)
{
options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter(
ClientPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = PermitLimit,
Window = Window,
QueueLimit = 0,
AutoReplenishment = true
}));
}
}

View file

@ -59,6 +59,7 @@ builder.Services.AddResponseCompression(opts =>
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
new[] { "application/octet-stream" });
});
builder.Services.AddPasswordResetRateLimiting();
builder.Services.AddCors(option =>
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
@ -195,6 +196,9 @@ builder.Services.AddSwaggerGen(c =>
var app = builder.Build();
// First, so every later middleware and the rate limiter see the real client address.
app.UseForwardedHeaders();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
{
@ -212,6 +216,7 @@ app.UseStaticFiles();
app.UseCors();
app.UseMiddleware<LegacyDeprecationMiddleware>();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseMiddleware<SentryRequestMetadataMiddleware>();
app.UseAuthorization();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class HashPasswordResetCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Pending codes were stored in plaintext with no expiry. They are
// short-lived, so they are discarded rather than migrated; affected users
// request a new code.
migrationBuilder.Sql("DELETE FROM [ForgetPasswordCodes];");
migrationBuilder.AddColumn<string>(
name: "CodeHash",
table: "ForgetPasswordCodes",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CodeSalt",
table: "ForgetPasswordCodes",
type: "nvarchar(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTime>(
name: "ExpiresAtUtc",
table: "ForgetPasswordCodes",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<int>(
name: "FailedAttempts",
table: "ForgetPasswordCodes",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CodeHash",
table: "ForgetPasswordCodes");
migrationBuilder.DropColumn(
name: "CodeSalt",
table: "ForgetPasswordCodes");
migrationBuilder.DropColumn(
name: "ExpiresAtUtc",
table: "ForgetPasswordCodes");
migrationBuilder.DropColumn(
name: "FailedAttempts",
table: "ForgetPasswordCodes");
}
}
}

View file

@ -1527,10 +1527,26 @@ namespace Data.SeaHavenIndustries.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CodeHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CodeSalt")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("datetime2");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(max)");

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@ -11,6 +12,23 @@ namespace Data.SeaHavenIndustries
public int Id { get; set; }
public string UserId { get; set; }
public string Email { get; set; }
public string Code { get; set; }
/// <summary>
/// Legacy plaintext column. It is always written empty; only
/// <see cref="CodeHash"/> is compared.
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>Lowercase hex SHA-256 of "<see cref="CodeSalt"/>:code".</summary>
[MaxLength(64)]
public string CodeHash { get; set; } = string.Empty;
[MaxLength(32)]
public string CodeSalt { get; set; } = string.Empty;
public DateTime ExpiresAtUtc { get; set; }
/// <summary>Checks consumed against this code, including concurrent ones still in flight.</summary>
public int FailedAttempts { get; set; }
}
}

View file

@ -13,71 +13,76 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken)
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var normalizedEmail = Normalize(email);
var existing = await _context.ForgetPasswordCodes
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.FirstOrDefaultAsync(cancellationToken);
.ToListAsync(cancellationToken);
_context.ForgetPasswordCodes.RemoveRange(existing);
if (existing != null)
{
_context.ForgetPasswordCodes.Remove(existing);
await _context.SaveChangesAsync(cancellationToken);
}
var forgetPasswordCode = new ForgetPasswordCode
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
{
Email = email,
UserId = userId,
Code = code
};
_context.ForgetPasswordCodes.Add(forgetPasswordCode);
Code = string.Empty,
CodeHash = codeHash,
CodeSalt = codeSalt,
ExpiresAtUtc = expiresAtUtc,
FailedAttempts = 0
});
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken)
{
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Code == code)
.AnyAsync(cancellationToken);
}
public async Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var trimmedCode = code.Trim();
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode)
.AnyAsync(cancellationToken);
}
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var normalizedEmail = Normalize(email);
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.OrderByDescending(u => u.Id)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
{
// A single conditional UPDATE, so concurrent checks can never consume
// more than maxAttempts between them.
var updated = await _context.ForgetPasswordCodes
.Where(u => u.Id == id && u.FailedAttempts < maxAttempts && u.ExpiresAtUtc > nowUtc)
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts + 1),
cancellationToken);
return updated == 1;
}
public async Task RefundAttemptAsync(int id, CancellationToken cancellationToken)
{
await _context.ForgetPasswordCodes
.Where(u => u.Id == id && u.FailedAttempts > 0)
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts - 1),
cancellationToken);
}
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var records = await _context.ForgetPasswordCodes
var normalizedEmail = Normalize(email);
await _context.ForgetPasswordCodes
.Where(code => code.Email.ToLower().Trim() == normalizedEmail)
.ToListAsync(cancellationToken);
if (records.Count == 0)
return;
_context.ForgetPasswordCodes.RemoveRange(records);
await _context.SaveChangesAsync(cancellationToken);
.ExecuteDeleteAsync(cancellationToken);
}
public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken)
{
var normalizedEmail = Normalize(email);
await _context.ForgetPasswordCodes
.Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId)
.ExecuteDeleteAsync(cancellationToken);
}
private static string Normalize(string email) => email.ToLower().Trim();
}
}

View file

@ -4,10 +4,27 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IForgetPasswordDataService
{
Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken);
Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken);
Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken);
/// <summary>Deletes every pending code for the email, then stores the new one.</summary>
Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken);
/// <summary>Returns the pending code for exactly this email, or null.</summary>
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);
/// <summary>
/// Atomically consumes one attempt when the code is unexpired and has fewer
/// than <paramref name="maxAttempts"/> consumed. Returns false otherwise.
/// </summary>
Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken);
/// <summary>Gives back an attempt consumed by a check that matched.</summary>
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
/// <summary>
/// Deletes the email's codes issued up to and including <paramref name="throughId"/>,
/// leaving any code issued after it by a concurrent request.
/// </summary>
Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,40 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Generation and hashing for emailed password reset codes. The raw code exists
/// only in memory and in the email sent to the account holder.
/// </summary>
public static class PasswordResetCodeSecrets
{
public static string NewCode()
{
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
}
public static string NewSalt()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
public static string Hash(string salt, string code)
{
ArgumentNullException.ThrowIfNull(salt);
ArgumentNullException.ThrowIfNull(code);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
}
public static bool Matches(string salt, string candidate, string expectedHash)
{
if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash))
return false;
var actual = Encoding.ASCII.GetBytes(Hash(salt, candidate.Trim()));
var expected = Encoding.ASCII.GetBytes(expectedHash);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}
}

View file

@ -9,7 +9,6 @@ using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Implementation
@ -21,18 +20,25 @@ namespace SeaHaven.Services.Implementation
private readonly IUserDataService _userDataService;
private readonly IForgetPasswordDataService _forgetPasswordDataService;
private readonly IEmailSender _emailSender;
private readonly TimeProvider _timeProvider;
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
public const int MaxCodeAttempts = 5;
public AuthenticationService(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions,
IUserDataService userDataService,
IForgetPasswordDataService forgetPasswordDataService,
IEmailSender emailSender)
IEmailSender emailSender,
TimeProvider timeProvider)
{
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
_userDataService = userDataService;
_forgetPasswordDataService = forgetPasswordDataService;
_emailSender = emailSender;
_timeProvider = timeProvider;
}
public async Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken)
@ -131,48 +137,102 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken)
public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken)
{
var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken);
if (user == null) return false;
// Registered and unregistered addresses take the same path up to the
// email send: one user lookup, one code generated and hashed, one write.
var requested = email?.Trim() ?? string.Empty;
var user = requested.Length == 0
? null
: await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
var code = PasswordResetCodeSecrets.NewCode();
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, code);
var code = GenerateRandomNo();
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken);
var body = $"Your Password Reset Code is: " + code;
await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body);
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
{
await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken);
return;
}
var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime);
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken);
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body);
}
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
{
var pending = await CheckCodeAsync(email, code, cancellationToken);
if (pending == null)
return false;
// Verifying is a preview step; a correct code keeps all of its attempts.
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
return true;
}
public async Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken)
public async Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken)
{
return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken);
}
public async Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password))
if (string.IsNullOrWhiteSpace(password))
return false;
var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken);
if (!matched) return false;
var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (record == null)
var pending = await CheckCodeAsync(email, code, cancellationToken);
if (pending == null)
return false;
var user = await _userManager.FindByIdAsync(record.UserId);
if (user == null)
var user = await _userManager.FindByIdAsync(pending.UserId);
if (user == null || user.IsDeleted == true)
{
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return false;
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, token, password);
if (!result.Succeeded)
{
// The code was right and the new password was rejected: the user can
// try another password without spending an attempt.
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
return false;
}
await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken);
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
return true;
}
/// <summary>
/// Returns the pending code record when <paramref name="code"/> matches the one
/// issued to <paramref name="email"/>. Every check consumes an attempt before
/// comparing; a check that uses the last attempt without matching deletes the code.
/// </summary>
private async Task<ForgetPasswordCode?> CheckCodeAsync(string? email, string? code, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code))
return null;
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (pending == null)
return null;
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
// Deletes below stop at this code's id: a code issued by a concurrent request
// after this one was read belongs to that request and must survive.
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
{
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
return pending;
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
private JwtSecurityToken GetToken(List<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
@ -185,10 +245,5 @@ namespace SeaHaven.Services.Implementation
);
return token;
}
private static string GenerateRandomNo()
{
return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6");
}
}
}

View file

@ -9,8 +9,13 @@ namespace SeaHaven.Services.Interfaces
Task<LoginResultDTO> CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken);
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken);
Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken);
/// <summary>
/// Emails a reset code when the address belongs to an active account. Gives no
/// indication either way, so callers cannot learn which emails are registered.
/// </summary>
Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken);
/// <summary>Checks a code against the one issued to this email only.</summary>
Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken);
Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,406 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// The anonymous Forgot Password flow over HTTP, against the real controller,
/// services, Identity and a relational database.
/// </summary>
public sealed class PasswordResetFlowTests
{
private const string Alice = "alice@example.com";
private const string Bob = "bob@example.com";
private const string OldPassword = "Old@12345";
private const string NewPassword = "New@67890";
private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}";
private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}";
[Fact]
public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var requested = await host.ForgetPasswordAsync(Alice);
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
var code = host.Sent.LatestCodeFor(Alice);
Assert.Matches("^[0-9]{6}$", code);
var verified = await host.VerifyAsync(Alice, code);
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync());
var reset = await host.ResetAsync(Alice, code, NewPassword);
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
// A used code cannot be replayed.
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync());
Assert.Empty(await host.PendingCodesAsync());
}
[Fact]
public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var registered = await host.ForgetPasswordAsync(Alice);
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
Assert.Equal(registered.StatusCode, unregistered.StatusCode);
Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync());
Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync());
var message = Assert.Single(host.Sent.Messages);
Assert.Equal(Alice, message.To);
Assert.Single(await host.PendingCodesAsync());
}
[Fact]
public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response()
{
await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender());
await host.AddUserAsync(Alice, OldPassword);
var registered = await host.ForgetPasswordAsync(Alice);
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
var body = await registered.Content.ReadAsStringAsync();
Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body);
Assert.DoesNotContain("smtp-internal-detail", body);
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var row = Assert.Single(await host.PendingCodesAsync());
Assert.Equal(string.Empty, row.Code);
Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt);
Assert.Matches("^[0-9a-f]{64}$", row.CodeHash);
Assert.NotEqual(Sha256Hex(code), row.CodeHash);
Assert.Equal(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash);
Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId));
}
[Fact]
public async Task Code_expires_fifteen_minutes_after_it_is_issued()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1));
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
host.Time.Advance(TimeSpan.FromSeconds(1));
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
}
[Fact]
public async Task Requesting_a_new_code_invalidates_the_previous_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var first = host.Sent.LatestCodeFor(Alice);
string second;
do
{
await host.ForgetPasswordAsync(Alice);
second = host.Sent.LatestCodeFor(Alice);
}
while (second == first);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync());
Assert.Single(await host.PendingCodesAsync());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode);
}
[Fact]
public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var wrong = WrongCode(code);
// Verify and reset share one attempt budget.
for (var attempt = 0; attempt < 3; attempt++)
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync());
for (var attempt = 0; attempt < 2; attempt++)
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync());
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
await host.ForgetPasswordAsync(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode);
}
[Fact]
public async Task Exhausting_a_code_invalidates_every_pending_code_for_that_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
var user = await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var older = host.Sent.LatestCodeFor(Alice);
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Alice, WrongCode(newest));
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync());
}
[Fact]
public async Task A_successful_reset_invalidates_every_pending_code_for_that_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
var user = await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var older = host.Sent.LatestCodeFor(Alice);
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode);
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync());
}
[Fact]
public async Task Four_wrong_attempts_then_the_right_code_still_resets()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 4; attempt++)
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync());
// A correct verify does not spend an attempt, so the reset after it still succeeds.
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
}
[Fact]
public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 6; attempt++)
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
}
[Fact]
public async Task A_code_is_checked_only_against_the_email_it_was_issued_to()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.AddUserAsync(Bob, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync(Bob);
var aliceCode = host.Sent.LatestCodeFor(Alice);
var bobCode = host.Sent.LatestCodeFor(Bob);
if (aliceCode != bobCode)
{
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync());
}
// Exhausting Bob's attempts leaves Alice's code and budget untouched.
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Bob, WrongCode(bobCode));
var alice = Assert.Single(await host.PendingCodesAsync());
Assert.Equal(Alice, alice.Email);
Assert.Equal(0, alice.FailedAttempts);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode);
}
[Fact]
public async Task Concurrent_checks_can_never_consume_more_than_five_attempts()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var row = Assert.Single(await host.PendingCodesAsync());
var now = host.Time.GetUtcNow().UtcDateTime;
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
{
await using var scope = host.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IForgetPasswordDataService>()
.TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None);
}));
Assert.Equal(5, results.Count(consumed => consumed));
Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
}
[Fact]
public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
// The old client contract: code in the query string, no body, no content type.
var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}"));
Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode);
Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync());
Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
}
[Fact]
public async Task VerificationCode_and_ForgetPassword_still_accept_the_query_string_form()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var requested = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}"));
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
var code = host.Sent.LatestCodeFor(Alice);
var verified = await host.Client.SendAsync(PasswordResetTestHost.Post(
$"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}"));
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
}
[Fact]
public async Task Each_endpoint_allows_ten_requests_per_client_per_window_then_answers_429()
{
await using var host = await PasswordResetTestHost.StartAsync();
const string client = "203.0.113.5";
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync($"n{request}@example.com", client)).StatusCode);
var limited = await host.ForgetPasswordAsync("n@example.com", client);
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
Assert.Equal(
"{\"status\":\"Error\",\"message\":\"Too many requests. Please try again later.\"}",
await limited.Content.ReadAsStringAsync());
// A left-hand X-Forwarded-For entry written by the caller does not buy a new bucket.
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.ForgetPasswordAsync("n@example.com", "198.51.100.1, " + client)).StatusCode);
// Another client, and the same client on another endpoint, are unaffected.
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync("n@example.com", "203.0.113.6")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync("n@example.com", "000000", client)).StatusCode);
}
[Theory]
[InlineData("api/Authentication/VerificationCode")]
[InlineData("api/Authentication/ResetPassword")]
public async Task Verify_and_reset_are_rate_limited_per_client(string path)
{
await using var host = await PasswordResetTestHost.StartAsync();
const string client = "203.0.113.9";
var body = new { email = Alice, code = "000000", password = NewPassword };
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
Assert.Equal(HttpStatusCode.BadRequest, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
}
[Fact]
public async Task No_code_or_email_reaches_the_logs_during_the_whole_flow()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync("nobody@example.com");
var code = host.Sent.LatestCodeFor(Alice);
await host.VerifyAsync(Alice, WrongCode(code));
await host.VerifyAsync(Alice, code);
await host.ResetAsync(Alice, code, NewPassword);
Assert.NotEmpty(host.Logged.Lines);
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(code, StringComparison.Ordinal));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(WrongCode(code), StringComparison.Ordinal));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains("@example.com", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(NewPassword, StringComparison.Ordinal));
}
[Fact]
public void Api_host_registers_forwarded_headers_and_rate_limiting_in_the_right_order()
{
var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs"));
Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", program);
var build = program.IndexOf("builder.Build()", StringComparison.Ordinal);
var forwarded = program.IndexOf("app.UseForwardedHeaders()", StringComparison.Ordinal);
var firstMiddleware = program.IndexOf("app.Use", build, StringComparison.Ordinal);
var routing = program.IndexOf("app.UseRouting()", StringComparison.Ordinal);
var limiter = program.IndexOf("app.UseRateLimiter()", StringComparison.Ordinal);
var controllers = program.IndexOf("app.MapControllers()", StringComparison.Ordinal);
Assert.True(forwarded > build && forwarded == firstMiddleware, "UseForwardedHeaders must be the first middleware.");
Assert.True(routing < limiter, "UseRateLimiter must run after UseRouting so endpoint policies apply.");
Assert.True(limiter < controllers);
}
private static string WrongCode(string code) =>
((int.Parse(code) + 1) % 1_000_000).ToString("D6");
private static string Sha256Hex(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string RepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null
&& !File.Exists(Path.Combine(directory.FullName, "Api.SeaHavenIndustries", "Program.cs")))
{
directory = directory.Parent;
}
return directory?.FullName
?? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory);
}
private sealed class ThrowingEmailSender : IEmailSender
{
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
throw new InvalidOperationException("smtp-internal-detail " + emailTo);
}
}

View file

@ -0,0 +1,118 @@
using System.Net;
using Data.SeaHavenIndustries;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// A new code requested while a check of the previous code is in flight. The
/// request runs for real, between the check's steps, through the same host.
/// </summary>
public sealed class PasswordResetRaceTests
{
private const string Alice = "alice@example.com";
private const string OldPassword = "Old@12345";
private const string NewPassword = "New@67890";
private static int _nextClient;
private static string NextClient()
{
var n = Interlocked.Increment(ref _nextClient);
return $"192.0.{n / 250 % 250}.{n % 250 + 1}";
}
[Fact]
public async Task A_code_requested_while_the_previous_one_is_being_checked_still_resets()
{
var race = new CheckRace();
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice, NextClient());
var first = host.Sent.LatestCodeFor(Alice);
// The check has read the first code; the new request replaces it before the attempt is counted.
race.BeforeNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, first, NextClient())).StatusCode);
Assert.Equal(2, host.Sent.Messages.Count);
var second = host.Sent.LatestCodeFor(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
}
[Fact]
public async Task A_code_requested_while_the_last_wrong_attempt_is_being_checked_still_resets()
{
var race = new CheckRace();
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice, NextClient());
var wrong = host.Sent.LatestCodeFor(Alice) == "000000" ? "111111" : "000000";
for (var attempt = 1; attempt < 5; attempt++)
await host.VerifyAsync(Alice, wrong, NextClient());
// The fifth wrong attempt is counted, then the new request lands before the used-up code is deleted.
race.AfterNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, wrong, NextClient())).StatusCode);
Assert.Equal(2, host.Sent.Messages.Count);
var second = host.Sent.LatestCodeFor(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
}
private sealed class CheckRace
{
private Func<Task>? _beforeNextConsume;
private Func<Task>? _afterNextConsume;
public Func<Task>? BeforeNextConsume { set => _beforeNextConsume = value; }
public Func<Task>? AfterNextConsume { set => _afterNextConsume = value; }
public void Register(IServiceCollection services) =>
services.Replace(ServiceDescriptor.Scoped<IForgetPasswordDataService>(provider =>
new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService<ApplicationDbContext>()), this)));
public Task RunBeforeConsumeAsync() => Interlocked.Exchange(ref _beforeNextConsume, null)?.Invoke() ?? Task.CompletedTask;
public Task RunAfterConsumeAsync() => Interlocked.Exchange(ref _afterNextConsume, null)?.Invoke() ?? Task.CompletedTask;
}
private sealed class RacingForgetPasswordDataService : IForgetPasswordDataService
{
private readonly IForgetPasswordDataService _inner;
private readonly CheckRace _race;
public RacingForgetPasswordDataService(IForgetPasswordDataService inner, CheckRace race)
{
_inner = inner;
_race = race;
}
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken) =>
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, cancellationToken);
public Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
_inner.GetByEmailAsync(email, cancellationToken);
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
{
await _race.RunBeforeConsumeAsync();
var consumed = await _inner.TryConsumeAttemptAsync(id, maxAttempts, nowUtc, cancellationToken);
await _race.RunAfterConsumeAsync();
return consumed;
}
public Task RefundAttemptAsync(int id, CancellationToken cancellationToken) =>
_inner.RefundAttemptAsync(id, cancellationToken);
public Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) =>
_inner.RemoveByEmailAsync(email, cancellationToken);
public Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) =>
_inner.RemoveIssuedThroughAsync(email, throughId, cancellationToken);
}
}

View file

@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Hosts the real <see cref="AuthenticationController"/> on Kestrel over a SQLite file
/// database, with the real authentication service, data services, Identity, and the
/// same rate limiting and forwarded-header registration the API host uses. Email goes
/// to an in-memory sender, the clock is manual, and every log line is captured.
/// </summary>
internal sealed class PasswordResetTestHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly string _databasePath;
private PasswordResetTestHost(WebApplication app, string databasePath, HttpClient client)
{
_app = app;
_databasePath = databasePath;
Client = client;
}
public HttpClient Client { get; }
public ResetEmailOutbox Sent { get; private init; } = null!;
public ResetTestClock Time { get; private init; } = null!;
public ResetLogCapture Logged { get; private init; } = null!;
public IServiceProvider Services => _app.Services;
public static async Task<PasswordResetTestHost> StartAsync(IEmailSender? emailSender = null, Action<IServiceCollection>? configureServices = null)
{
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
var sent = new ResetEmailOutbox();
var time = new ResetTestClock();
var logged = new ResetLogCapture();
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:Secret"] = new string('k', 64),
["JWT:ValidIssuer"] = "issuer",
["JWT:ValidAudience"] = "audience"
});
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.Trace);
builder.Logging.AddProvider(logged);
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqlitePasswordResetDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddSingleton<TimeProvider>(time);
builder.Services.AddSingleton<IEmailSender>(emailSender ?? sent);
builder.Services.AddDataServices();
configureServices?.Invoke(builder.Services);
builder.Services.AddBusinessServices(builder.Configuration);
builder.Services.AddControllers()
.AddApplicationPart(typeof(AuthenticationController).Assembly)
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Clear();
manager.FeatureProviders.Add(new OnlyAuthenticationController());
});
builder.Services.AddPasswordResetRateLimiting();
var app = builder.Build();
app.UseForwardedHeaders();
app.UseRouting();
app.UseRateLimiter();
app.MapControllers();
await using (var scope = app.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.EnsureCreatedAsync();
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features
.Get<IServerAddressesFeature>()!.Addresses.Single();
return new PasswordResetTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) })
{
Sent = sent,
Time = time,
Logged = logged
};
}
public async Task<ApplicationUser> AddUserAsync(string email, string password)
{
await using var scope = _app.Services.CreateAsyncScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = "Alice",
LastName = "Q",
EmailConfirmed = true,
CreatedDate = DateTime.UtcNow
};
var result = await users.CreateAsync(user, password);
Assert.True(result.Succeeded, string.Join("; ", result.Errors.Select(error => error.Description)));
return user;
}
public async Task<List<ForgetPasswordCode>> PendingCodesAsync()
{
await using var scope = _app.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
.ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync();
}
/// <summary>
/// Stores a second pending code for the email directly, as two concurrent first
/// requests could, and returns it. The new row is the newest one.
/// </summary>
public async Task<string> AddSiblingCodeAsync(string email, string userId)
{
const string code = "424242";
const string salt = "0123456789abcdef0123456789abcdef";
await using var scope = _app.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
context.ForgetPasswordCodes.Add(new ForgetPasswordCode
{
Email = email,
UserId = userId,
CodeSalt = salt,
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
});
await context.SaveChangesAsync();
return code;
}
public static HttpRequestMessage Post(string path, object? json = null, string? clientIp = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, path);
if (json != null)
request.Content = JsonContent.Create(json);
if (clientIp != null)
request.Headers.Add("X-Forwarded-For", clientIp);
return request;
}
public Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
public Task<HttpResponseMessage> VerifyAsync(string email, string code, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp));
public Task<HttpResponseMessage> ResetAsync(string email, string code, string password, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/ResetPassword", new { email, code, password }, clientIp));
public Task<HttpResponseMessage> LoginAsync(string username, string password) =>
Client.SendAsync(Post("api/Authentication/login", new { username, password }));
public async ValueTask DisposeAsync()
{
Client.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
{
if (File.Exists(path))
File.Delete(path);
}
}
private sealed class OnlyAuthenticationController : ControllerFeatureProvider
{
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
typeInfo.AsType() == typeof(AuthenticationController);
}
private sealed class SqlitePasswordResetDbContext : ApplicationDbContext
{
public SqlitePasswordResetDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
// SQL Server filter syntax does not carry over; a filtered unique index
// without its filter would wrongly reject a second user.
if (index.GetFilter() is not null)
{
index.SetFilter(null);
index.IsUnique = false;
}
}
foreach (var property in builder.Model.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
{
property.ValueGenerated = ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}
internal sealed class ResetTestClock : TimeProvider
{
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
internal sealed class ResetEmailOutbox : IEmailSender
{
private static readonly Regex CodePattern = new(@"Your Password Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
public ConcurrentQueue<(string To, string Subject, string Body)> Messages { get; } = new();
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
Messages.Enqueue((emailTo, subject, body));
return Task.FromResult(true);
}
public string LatestCodeFor(string email)
{
var message = Messages.Last(sent => string.Equals(sent.To, email, StringComparison.OrdinalIgnoreCase));
return CodePattern.Match(message.Body).Groups[1].Value;
}
}
internal sealed class ResetLogCapture : ILoggerProvider
{
public ConcurrentQueue<string> Lines { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly ResetLogCapture _owner;
private readonly string _category;
public CapturingLogger(ResetLogCapture owner, string category)
{
_owner = owner;
_category = category;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
: string.Empty;
_owner.Lines.Enqueue($"{logLevel} {_category} {formatter(state, exception)} {values} {exception}");
}
}
}