mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
fix(auth): invalidate every pending reset code for an email together
Two concurrent first requests can leave two pending codes for one email. Exhausting or using one now deletes all of them, so a sibling code cannot become live afterwards.
This commit is contained in:
parent
c841e130be
commit
bcc9b6d7a2
6 changed files with 58 additions and 13 deletions
|
|
@ -206,7 +206,7 @@ public class AuthenticationServiceTests
|
|||
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
forget.Verify(f => f.RemoveAsync(7, It.IsAny<CancellationToken>()), Times.Once);
|
||||
forget.Verify(f => f.RemoveByEmailAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,13 +67,6 @@ namespace SeaHaven.DataServices.Implementation
|
|||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = Normalize(email);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
/// <summary>Gives back an attempt consumed by a check that matched.</summary>
|
||||
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveAsync(int id, CancellationToken cancellationToken);
|
||||
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ namespace SeaHaven.Services.Implementation
|
|||
var user = await _userManager.FindByIdAsync(pending.UserId);
|
||||
if (user == null || user.IsDeleted == true)
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken);
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ namespace SeaHaven.Services.Implementation
|
|||
return false;
|
||||
}
|
||||
|
||||
await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken);
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +199,7 @@ namespace SeaHaven.Services.Implementation
|
|||
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken);
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ namespace SeaHaven.Services.Implementation
|
|||
return pending;
|
||||
|
||||
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
||||
await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken);
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,6 +163,37 @@ public sealed class PasswordResetFlowTests
|
|||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exhausting_a_code_invalidates_every_pending_code_for_that_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
var user = await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var older = host.Sent.LatestCodeFor(Alice);
|
||||
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Alice, WrongCode(newest));
|
||||
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_reset_invalidates_every_pending_code_for_that_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
var user = await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var older = host.Sent.LatestCodeFor(Alice);
|
||||
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode);
|
||||
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Four_wrong_attempts_then_the_right_code_still_resets()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -137,6 +137,28 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
|||
.ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores a second pending code for the email directly, as two concurrent first
|
||||
/// requests could, and returns it. The new row is the newest one.
|
||||
/// </summary>
|
||||
public async Task<string> AddSiblingCodeAsync(string email, string userId)
|
||||
{
|
||||
const string code = "424242";
|
||||
const string salt = "0123456789abcdef0123456789abcdef";
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
context.ForgetPasswordCodes.Add(new ForgetPasswordCode
|
||||
{
|
||||
Email = email,
|
||||
UserId = userId,
|
||||
CodeSalt = salt,
|
||||
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
|
||||
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return code;
|
||||
}
|
||||
|
||||
public static HttpRequestMessage Post(string path, object? json = null, string? clientIp = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue