diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 0ac9cde..2fd6382 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -157,17 +157,53 @@ public class AuthenticationServiceTests var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); var forget = new Mock(); + var stored = new List<(string Hash, string Salt)>(); + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, _, _, _) => stored.Add((h, s))) + .Returns(Task.CompletedTask); var email = new Mock(); - email.SetupSequence(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) - .Returns(false).Returns(false).Returns(false).Returns(true); + var bodies = new List(); + // The queue is full for the first three requests. + email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) + .Returns((_, _, b) => + { + bodies.Add(b); + return bodies.Count > 3; + }); var service = NewService(userData, forget, email, out _, out _); for (var request = 0; request < 4; request++) await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - // Three undelivered codes were removed, and they did not use up the hourly limit of three. - forget.Verify(f => f.RemoveByEmailAsync(user.Email!, It.IsAny()), Times.Exactly(3)); + // The three undelivered requests did not use up the hourly limit of three. email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny()), Times.Exactly(4)); + stored.Should().HaveCount(4); + for (var request = 0; request < 3; request++) + { + var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value; + PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeFalse(); + } + var delivered = System.Text.RegularExpressions.Regex.Match(bodies[3], @"Your Password Reset Code is: (\d{6})").Groups[1].Value; + PasswordResetCodeSecrets.Matches(ResetKey, stored[3].Salt, delivered, stored[3].Hash).Should().BeTrue(); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); + var registered = new Mock(); + var unregistered = new Mock(); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); + + await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); + await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None); + + registered.Invocations.Select(call => call.Method.Name) + .Should().Equal(unregistered.Invocations.Select(call => call.Method.Name)) + .And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync)); } [Fact] diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 32cc06c..f07ff28 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -150,25 +150,25 @@ namespace SeaHaven.Services.Implementation var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code); var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email); + var queued = false; + if (active) + { + var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; + queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); + + // A code that cannot be emailed is stored unmatchable and the request is not counted. + if (!queued) + _resetThrottle.ReleaseCodeRequest(requested); + } + await _forgetPasswordDataService.ReplaceCodeAsync( active ? user!.Email! : requested, active ? user!.Id : string.Empty, - active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), + queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), salt, nowUtc.Add(ResetCodeLifetime), nowUtc, cancellationToken); - - if (active) - { - var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; - if (!_resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body)) - { - // The code will never reach the user: drop it and do not count the request. - await _forgetPasswordDataService.RemoveByEmailAsync(user.Email!, cancellationToken); - _resetThrottle.ReleaseCodeRequest(requested); - } - } } public async Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)