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:
Alexandre Brandizzi 2026-09-25 12:31:17 -03:00
parent c841e130be
commit bcc9b6d7a2
6 changed files with 58 additions and 13 deletions

View file

@ -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);
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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()
{

View file

@ -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);