fix(auth): store an unmatchable code when the reset email cannot be queued, keeping data calls identical

This commit is contained in:
Alexandre Brandizzi 2026-09-25 13:38:11 -03:00
parent a6dd40b972
commit 57af8a1206
2 changed files with 52 additions and 16 deletions

View file

@ -157,17 +157,53 @@ public class AuthenticationServiceTests
var userData = new Mock<IUserDataService>(); var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>(); var forget = new Mock<IForgetPasswordDataService>();
var stored = new List<(string Hash, string Salt)>();
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, _, _, _) => stored.Add((h, s)))
.Returns(Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>(); var email = new Mock<IPasswordResetEmailQueue>();
email.SetupSequence(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>())) var bodies = new List<string>();
.Returns(false).Returns(false).Returns(false).Returns(true); // The queue is full for the first three requests.
email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()))
.Returns<string, string, string>((_, _, b) =>
{
bodies.Add(b);
return bodies.Count > 3;
});
var service = NewService(userData, forget, email, out _, out _); var service = NewService(userData, forget, email, out _, out _);
for (var request = 0; request < 4; request++) for (var request = 0; request < 4; request++)
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
// Three undelivered codes were removed, and they did not use up the hourly limit of three. // The three undelivered requests did not use up the hourly limit of three.
forget.Verify(f => f.RemoveByEmailAsync(user.Email!, It.IsAny<CancellationToken>()), Times.Exactly(3));
email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4)); email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).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] [Fact]

View file

@ -150,25 +150,25 @@ namespace SeaHaven.Services.Implementation
var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code); var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code);
var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email); 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( await _forgetPasswordDataService.ReplaceCodeAsync(
active ? user!.Email! : requested, active ? user!.Email! : requested,
active ? user!.Id : string.Empty, active ? user!.Id : string.Empty,
active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
salt, salt,
nowUtc.Add(ResetCodeLifetime), nowUtc.Add(ResetCodeLifetime),
nowUtc, nowUtc,
cancellationToken); 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<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)