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; /// /// Limits that hold per account rather than per client address, the email send /// being off the request path, and the keyed code hash. /// 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(); 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() where T : notnull { using var scope = host.Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } Assert.Same(Resolve(), Resolve()); Assert.Same(Resolve(), Resolve()); Assert.Same(Resolve(), Resolve()); Assert.Contains(host.Services.GetServices(), 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 SendEmailAsync(string emailTo, string subject, string body) { await _gate.Task; Completed = true; return true; } } }