shoc-backend/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs

270 lines
11 KiB
C#
Raw Normal View History

using System.Net;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.HostedServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Limits that hold per account rather than per client address, the email send
/// being off the request path, and the keyed code hash.
/// </summary>
public sealed class PasswordResetAbuseLimitsTests
{
private const string Alice = "alice@example.com";
private const string OldPassword = "Old@12345";
private const string NewPassword = "New@67890";
private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}";
private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}";
private static int _nextClient;
// A fresh client address per request, so the per-IP limit never masks the per-account one.
private static string NextClient()
{
var n = Interlocked.Increment(ref _nextClient);
return $"198.51.{n / 250 % 250}.{n % 250 + 1}";
}
[Fact]
public async Task Code_requests_are_capped_per_email_at_three_an_hour_and_ten_a_day()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var responses = new List<string>();
for (var request = 0; request < 3; request++)
responses.Add(await (await host.ForgetPasswordAsync(Alice, NextClient())).Content.ReadAsStringAsync());
var liveCode = host.Sent.LatestCodeFor(Alice);
// A differently cased and padded address is the same account.
responses.Add(await (await host.ForgetPasswordAsync(" ALICE@Example.com ", NextClient())).Content.ReadAsStringAsync());
Assert.Equal(3, host.Sent.Messages.Count);
Assert.Single(responses.Distinct());
// The refused request neither sent a code nor replaced the one already sent.
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, liveCode, NextClient())).StatusCode);
for (var hour = 1; hour <= 3; hour++)
{
host.Time.Advance(TimeSpan.FromHours(1));
for (var request = 0; request < 3; request++)
await host.ForgetPasswordAsync(Alice, NextClient());
}
Assert.Equal(10, host.Sent.Messages.Count);
host.Time.Advance(TimeSpan.FromHours(20));
await host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(10, host.Sent.Messages.Count);
host.Time.Advance(TimeSpan.FromHours(1));
await host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(11, host.Sent.Messages.Count);
}
[Fact]
public async Task Failed_checks_are_capped_per_account_across_every_code_it_is_sent()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
for (var round = 0; round < 2; round++)
{
await host.ForgetPasswordAsync(Alice, NextClient());
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Alice, WrongCode(code), NextClient());
}
await host.ForgetPasswordAsync(Alice, NextClient());
var third = host.Sent.LatestCodeFor(Alice);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, third, NextClient())).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, third, NewPassword, NextClient())).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
host.Time.Advance(TimeSpan.FromDays(1));
await host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword, NextClient())).StatusCode);
}
[Fact]
public async Task Checks_without_a_live_code_do_not_use_up_the_account_budget()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
// No code issued yet.
for (var check = 0; check < 10; check++)
{
await host.VerifyAsync(Alice, "123456", NextClient());
await host.ResetAsync(Alice, "123456", NewPassword, NextClient());
}
// A code that has expired.
await host.ForgetPasswordAsync(Alice, NextClient());
var expired = host.Sent.LatestCodeFor(Alice);
host.Time.Advance(TimeSpan.FromMinutes(16));
for (var check = 0; check < 5; check++)
await host.VerifyAsync(Alice, expired, NextClient());
await host.ForgetPasswordAsync(Alice, NextClient());
var live = host.Sent.LatestCodeFor(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, live, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, live, NewPassword, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
}
[Fact]
public void Concurrent_checks_can_never_exceed_the_account_budget()
{
var throttle = new InMemoryPasswordResetThrottle(new ResetTestClock());
var reserved = 0;
Parallel.For(0, 64, _ =>
{
if (throttle.TryReserveCheck(Alice))
Interlocked.Increment(ref reserved);
});
Assert.Equal(InMemoryPasswordResetThrottle.FailedChecksPerDay, reserved);
}
[Fact]
public void A_matching_check_gives_its_reservation_back()
{
var throttle = new InMemoryPasswordResetThrottle(new ResetTestClock());
for (var check = 0; check < 50; check++)
{
Assert.True(throttle.TryReserveCheck(Alice));
throttle.ReleaseCheck(Alice);
}
Assert.True(throttle.TryReserveCheck(Alice.ToUpperInvariant()));
}
[Fact]
public async Task ForgetPassword_answers_before_the_email_is_sent()
{
var sender = new BlockingEmailSender();
await using var host = await PasswordResetTestHost.StartAsync(sender);
await host.AddUserAsync(Alice, OldPassword);
try
{
var response = await host.Client
.SendAsync(PasswordResetTestHost.Post("api/Authentication/ForgetPassword", new { email = Alice }))
.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains(AuthenticationController.ForgetPasswordMessage, await response.Content.ReadAsStringAsync());
Assert.False(sender.Completed);
}
finally
{
sender.Release();
}
await host.WaitForEmailDrainAsync();
Assert.True(sender.Completed);
}
[Fact]
public async Task An_unregistered_email_gets_the_same_database_write_but_a_code_nothing_can_match()
{
await using var host = await PasswordResetTestHost.StartAsync();
var alice = await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync("nobody@example.com");
var rows = await host.PendingCodesAsync();
Assert.Equal(2, rows.Count);
var registered = Assert.Single(rows, row => row.Email == Alice);
var unregistered = Assert.Single(rows, row => row.Email == "nobody@example.com");
Assert.Equal(alice.Id, registered.UserId);
Assert.Equal(string.Empty, unregistered.UserId);
Assert.Matches("^[0-9a-f]{64}$", unregistered.CodeHash);
Assert.Equal(registered.ExpiresAtUtc, unregistered.ExpiresAtUtc);
Assert.Single(host.Sent.Messages);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync("nobody@example.com", "000000")).Content.ReadAsStringAsync());
}
[Fact]
public async Task Each_request_clears_expired_codes_so_decoy_rows_do_not_accumulate()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.ForgetPasswordAsync("first@example.com");
await host.ForgetPasswordAsync("second@example.com");
host.Time.Advance(TimeSpan.FromMinutes(16));
await host.ForgetPasswordAsync("third@example.com");
var row = Assert.Single(await host.PendingCodesAsync());
Assert.Equal("third@example.com", row.Email);
}
[Fact]
public async Task A_code_stored_in_the_previous_unkeyed_format_no_longer_matches()
{
await using var host = await PasswordResetTestHost.StartAsync();
var alice = await host.AddUserAsync(Alice, OldPassword);
var code = await host.AddSiblingCodeAsync(Alice, alice.Id, keyedHash: false);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
}
[Fact]
public void A_code_hashed_under_another_server_secret_does_not_match()
{
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('a', 64)), salt, "123456");
Assert.True(PasswordResetCodeSecrets.Matches(PasswordResetCodeSecrets.DeriveKey(new string('a', 64)), salt, "123456", hash));
Assert.False(PasswordResetCodeSecrets.Matches(PasswordResetCodeSecrets.DeriveKey(new string('b', 64)), salt, "123456", hash));
}
[Fact]
public async Task Reset_queue_and_throttle_are_process_wide_and_the_sender_runs()
{
await using var host = await PasswordResetTestHost.StartAsync();
object Resolve<T>() where T : notnull
{
using var scope = host.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<T>();
}
Assert.Same(Resolve<IPasswordResetEmailQueue>(), Resolve<IPasswordResetEmailQueue>());
Assert.Same(Resolve<IPasswordResetThrottle>(), Resolve<IPasswordResetThrottle>());
Assert.Same(Resolve<PasswordResetEmailChannel>(), Resolve<IPasswordResetEmailQueue>());
Assert.Contains(host.Services.GetServices<IHostedService>(), service => service is PasswordResetEmailSenderHostedService);
}
private static string WrongCode(string code) =>
((int.Parse(code) + 1) % 1_000_000).ToString("D6");
private sealed class BlockingEmailSender : IEmailSender
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool Completed { get; private set; }
public void Release() => _gate.TrySetResult();
public async Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
await _gate.Task;
Completed = true;
return true;
}
}
}