mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and an account gets 10 failed code checks a day across every code it is sent, so new client addresses and new codes no longer buy more guesses. Refused requests answer exactly like accepted ones. - The reset email is queued to a background sender, and unregistered addresses store a row no code can match, so both paths do the same work and return without waiting on the mail provider. Each request also clears expired codes. - Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT signing secret; rows in the previous unkeyed format stop matching. - Email and code are read only from the JSON body.
241 lines
9.8 KiB
C#
241 lines
9.8 KiB
C#
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 void Concurrent_checks_can_never_exceed_the_account_budget()
|
|
{
|
|
var throttle = new InMemoryPasswordResetThrottle(new ManualTimeProvider());
|
|
|
|
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 ManualTimeProvider());
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|