mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +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);
|
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)]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue