mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2
# Conflicts: # SeaHaven.Services/Implementation/AuthenticationService.cs
This commit is contained in:
commit
1277642ede
6 changed files with 143 additions and 5 deletions
|
|
@ -267,7 +267,7 @@ public class AuthenticationServiceTests
|
||||||
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeFalse();
|
result.Should().BeFalse();
|
||||||
forget.Verify(f => f.RemoveByEmailAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
|
forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedEmail = Normalize(email);
|
||||||
|
await _context.ForgetPasswordCodes
|
||||||
|
.Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Normalize(string email) => email.ToLower().Trim();
|
private static string Normalize(string email) => email.ToLower().Trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,11 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
|
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
|
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the email's codes issued up to and including <paramref name="throughId"/>,
|
||||||
|
/// leaving any code issued after it by a concurrent request.
|
||||||
|
/// </summary>
|
||||||
|
Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
var user = await _userManager.FindByIdAsync(pending.UserId);
|
var user = await _userManager.FindByIdAsync(pending.UserId);
|
||||||
if (user == null || user.IsDeleted == true)
|
if (user == null || user.IsDeleted == true)
|
||||||
{
|
{
|
||||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,11 +250,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
|
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))
|
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
|
||||||
{
|
{
|
||||||
// Expired or out of attempts: nothing was compared, so no guess is counted.
|
// Expired or out of attempts: nothing was compared, so no guess is counted.
|
||||||
_resetThrottle.ReleaseCheck(email);
|
_resetThrottle.ReleaseCheck(email);
|
||||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,7 +267,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
||||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
121
SeaHavenIndustries.Tests/PasswordResetRaceTests.cs
Normal file
121
SeaHavenIndustries.Tests/PasswordResetRaceTests.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System.Net;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A new code requested while a check of the previous code is in flight. The
|
||||||
|
/// request runs for real, between the check's steps, through the same host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasswordResetRaceTests
|
||||||
|
{
|
||||||
|
private const string Alice = "alice@example.com";
|
||||||
|
private const string OldPassword = "Old@12345";
|
||||||
|
private const string NewPassword = "New@67890";
|
||||||
|
|
||||||
|
private static int _nextClient;
|
||||||
|
|
||||||
|
private static string NextClient()
|
||||||
|
{
|
||||||
|
var n = Interlocked.Increment(ref _nextClient);
|
||||||
|
return $"192.0.{n / 250 % 250}.{n % 250 + 1}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_code_requested_while_the_previous_one_is_being_checked_still_resets()
|
||||||
|
{
|
||||||
|
var race = new CheckRace();
|
||||||
|
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
|
||||||
|
await host.AddUserAsync(Alice, OldPassword);
|
||||||
|
await host.ForgetPasswordAsync(Alice, NextClient());
|
||||||
|
var first = host.Sent.LatestCodeFor(Alice);
|
||||||
|
|
||||||
|
// The check has read the first code; the new request replaces it before the attempt is counted.
|
||||||
|
race.BeforeNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, first, NextClient())).StatusCode);
|
||||||
|
|
||||||
|
Assert.Equal(2, host.Sent.Messages.Count);
|
||||||
|
var second = host.Sent.LatestCodeFor(Alice);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_code_requested_while_the_last_wrong_attempt_is_being_checked_still_resets()
|
||||||
|
{
|
||||||
|
var race = new CheckRace();
|
||||||
|
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
|
||||||
|
await host.AddUserAsync(Alice, OldPassword);
|
||||||
|
await host.ForgetPasswordAsync(Alice, NextClient());
|
||||||
|
var wrong = host.Sent.LatestCodeFor(Alice) == "000000" ? "111111" : "000000";
|
||||||
|
for (var attempt = 1; attempt < 5; attempt++)
|
||||||
|
await host.VerifyAsync(Alice, wrong, NextClient());
|
||||||
|
|
||||||
|
// The fifth wrong attempt is counted, then the new request lands before the used-up code is deleted.
|
||||||
|
race.AfterNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, wrong, NextClient())).StatusCode);
|
||||||
|
|
||||||
|
Assert.Equal(2, host.Sent.Messages.Count);
|
||||||
|
var second = host.Sent.LatestCodeFor(Alice);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CheckRace
|
||||||
|
{
|
||||||
|
private Func<Task>? _beforeNextConsume;
|
||||||
|
private Func<Task>? _afterNextConsume;
|
||||||
|
|
||||||
|
public Func<Task>? BeforeNextConsume { set => _beforeNextConsume = value; }
|
||||||
|
public Func<Task>? AfterNextConsume { set => _afterNextConsume = value; }
|
||||||
|
|
||||||
|
public void Register(IServiceCollection services) =>
|
||||||
|
services.Replace(ServiceDescriptor.Scoped<IForgetPasswordDataService>(provider =>
|
||||||
|
new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService<ApplicationDbContext>()), this)));
|
||||||
|
|
||||||
|
public Task RunBeforeConsumeAsync() => Interlocked.Exchange(ref _beforeNextConsume, null)?.Invoke() ?? Task.CompletedTask;
|
||||||
|
public Task RunAfterConsumeAsync() => Interlocked.Exchange(ref _afterNextConsume, null)?.Invoke() ?? Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RacingForgetPasswordDataService : IForgetPasswordDataService
|
||||||
|
{
|
||||||
|
private readonly IForgetPasswordDataService _inner;
|
||||||
|
private readonly CheckRace _race;
|
||||||
|
|
||||||
|
public RacingForgetPasswordDataService(IForgetPasswordDataService inner, CheckRace race)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_race = race;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken) =>
|
||||||
|
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, nowUtc, cancellationToken);
|
||||||
|
|
||||||
|
public Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken) =>
|
||||||
|
_inner.PurgeExpiredAsync(nowUtc, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||||
|
_inner.GetByEmailAsync(email, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _race.RunBeforeConsumeAsync();
|
||||||
|
var consumed = await _inner.TryConsumeAttemptAsync(id, maxAttempts, nowUtc, cancellationToken);
|
||||||
|
await _race.RunAfterConsumeAsync();
|
||||||
|
return consumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RefundAttemptAsync(int id, CancellationToken cancellationToken) =>
|
||||||
|
_inner.RefundAttemptAsync(id, cancellationToken);
|
||||||
|
|
||||||
|
public Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||||
|
_inner.RemoveByEmailAsync(email, cancellationToken);
|
||||||
|
|
||||||
|
public Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) =>
|
||||||
|
_inner.RemoveIssuedThroughAsync(email, throughId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
||||||
public CapturingLoggerProvider Logged { get; private init; } = null!;
|
public CapturingLoggerProvider Logged { get; private init; } = null!;
|
||||||
public IServiceProvider Services => _app.Services;
|
public IServiceProvider Services => _app.Services;
|
||||||
|
|
||||||
public static async Task<PasswordResetTestHost> StartAsync(IEmailSender? emailSender = null)
|
public static async Task<PasswordResetTestHost> StartAsync(IEmailSender? emailSender = null, Action<IServiceCollection>? configureServices = null)
|
||||||
{
|
{
|
||||||
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
|
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
|
||||||
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
|
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
|
||||||
|
|
@ -84,6 +84,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
||||||
builder.Services.AddSingleton<TimeProvider>(time);
|
builder.Services.AddSingleton<TimeProvider>(time);
|
||||||
builder.Services.AddSingleton<IEmailSender>(emailSender ?? sent);
|
builder.Services.AddSingleton<IEmailSender>(emailSender ?? sent);
|
||||||
builder.Services.AddDataServices();
|
builder.Services.AddDataServices();
|
||||||
|
configureServices?.Invoke(builder.Services);
|
||||||
builder.Services.AddBusinessServices(builder.Configuration);
|
builder.Services.AddBusinessServices(builder.Configuration);
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
.AddApplicationPart(typeof(AuthenticationController).Assembly)
|
.AddApplicationPart(typeof(AuthenticationController).Assembly)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue