mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
fix(auth): give back reset check and request slots when a data call fails or is cancelled
This commit is contained in:
parent
1277642ede
commit
f0dcba63fd
2 changed files with 128 additions and 37 deletions
|
|
@ -271,6 +271,84 @@ public class AuthenticationServiceTests
|
|||
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]
|
||||
[InlineData("123456", "123456", true)]
|
||||
[InlineData("123456", " 123456 ", true)]
|
||||
|
|
|
|||
|
|
@ -164,15 +164,17 @@ namespace SeaHaven.Services.Implementation
|
|||
var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code);
|
||||
var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email);
|
||||
|
||||
// The request stays counted only once its row is stored. A code that cannot be
|
||||
// emailed is stored unmatchable and not counted, and a failed or cancelled
|
||||
// write is not counted either.
|
||||
var counted = false;
|
||||
try
|
||||
{
|
||||
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(
|
||||
|
|
@ -183,6 +185,13 @@ namespace SeaHaven.Services.Implementation
|
|||
nowUtc.Add(ResetCodeLifetime),
|
||||
nowUtc,
|
||||
cancellationToken);
|
||||
counted = queued || !active;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!counted)
|
||||
_resetThrottle.ReleaseCodeRequest(requested);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
|
||||
|
|
@ -242,34 +251,38 @@ namespace SeaHaven.Services.Implementation
|
|||
if (!_resetThrottle.TryReserveCheck(email))
|
||||
return null;
|
||||
|
||||
// Only a wrong code actually compared keeps the slot. No live code, an expired or
|
||||
// used-up code, a match, and a failed or cancelled call all give it back.
|
||||
var wrongGuess = false;
|
||||
try
|
||||
{
|
||||
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
||||
if (pending == null)
|
||||
{
|
||||
_resetThrottle.ReleaseCheck(email);
|
||||
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))
|
||||
{
|
||||
// Expired or out of attempts: nothing was compared, so no guess is counted.
|
||||
_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;
|
||||
}
|
||||
|
||||
wrongGuess = true;
|
||||
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!wrongGuess)
|
||||
_resetThrottle.ReleaseCheck(email);
|
||||
}
|
||||
}
|
||||
|
||||
private JwtSecurityToken GetToken(List<Claim> authClaims)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue