fix(auth): give back reset check and request slots when a data call fails or is cancelled

This commit is contained in:
Alexandre Brandizzi 2026-09-25 17:38:51 -03:00
parent 1277642ede
commit f0dcba63fd
2 changed files with 128 additions and 37 deletions

View file

@ -271,6 +271,84 @@ public class AuthenticationServiceTests
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
} }
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task VerifyCode_FailedOrCancelledLookups_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
var forget = new Mock<IForgetPasswordDataService>();
var lookups = 0;
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>()))
.Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<ForgetPasswordCode?>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult<ForgetPasswordCode?>(pending));
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task VerifyCode_FailedOrCancelledAttemptConsumes_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
var consumes = 0;
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<bool>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult(true));
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ForgetPassword_FailedOrCancelledWrites_DoNotUseUpTheEmailRequestLimit(bool cancelled)
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var writes = 0;
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour
? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
var service = NewService(userData, forget, email, out _, out _);
for (var request = 0; request < InMemoryPasswordResetThrottle.CodeRequestsPerHour; request++)
{
var act = () => service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1);
forget.Verify(f => f.PurgeExpiredAsync(It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory] [Theory]
[InlineData("123456", "123456", true)] [InlineData("123456", "123456", true)]
[InlineData("123456", " 123456 ", true)] [InlineData("123456", " 123456 ", true)]

View file

@ -164,25 +164,34 @@ 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; // The request stays counted only once its row is stored. A code that cannot be
if (active) // emailed is stored unmatchable and not counted, and a failed or cancelled
// write is not counted either.
var counted = false;
try
{ {
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; var queued = false;
queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); 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. await _forgetPasswordDataService.ReplaceCodeAsync(
if (!queued) active ? user!.Email! : requested,
active ? user!.Id : string.Empty,
queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
salt,
nowUtc.Add(ResetCodeLifetime),
nowUtc,
cancellationToken);
counted = queued || !active;
}
finally
{
if (!counted)
_resetThrottle.ReleaseCodeRequest(requested); _resetThrottle.ReleaseCodeRequest(requested);
} }
await _forgetPasswordDataService.ReplaceCodeAsync(
active ? user!.Email! : requested,
active ? user!.Id : string.Empty,
queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
salt,
nowUtc.Add(ResetCodeLifetime),
nowUtc,
cancellationToken);
} }
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
@ -242,33 +251,37 @@ namespace SeaHaven.Services.Implementation
if (!_resetThrottle.TryReserveCheck(email)) if (!_resetThrottle.TryReserveCheck(email))
return null; return null;
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); // Only a wrong code actually compared keeps the slot. No live code, an expired or
if (pending == null) // used-up code, a match, and a failed or cancelled call all give it back.
var wrongGuess = false;
try
{ {
_resetThrottle.ReleaseCheck(email); 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(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash))
return pending;
wrongGuess = true;
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null; return null;
} }
finally
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))
{ {
// Expired or out of attempts: nothing was compared, so no guess is counted. if (!wrongGuess)
_resetThrottle.ReleaseCheck(email); _resetThrottle.ReleaseCheck(email);
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
} }
if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash))
{
_resetThrottle.ReleaseCheck(email);
return pending;
}
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
} }
private JwtSecurityToken GetToken(List<Claim> authClaims) private JwtSecurityToken GetToken(List<Claim> authClaims)