shoc-backend/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs

419 lines
20 KiB
C#
Raw Normal View History

using System.Net;
using System.Security.Cryptography;
using System.Text;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// The anonymous Forgot Password flow over HTTP, against the real controller,
/// services, Identity and a relational database.
/// </summary>
public sealed class PasswordResetFlowTests
{
private const string Alice = "alice@example.com";
private const string Bob = "bob@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\"}";
[Fact]
public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var requested = await host.ForgetPasswordAsync(Alice);
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
var code = host.Sent.LatestCodeFor(Alice);
Assert.Matches("^[0-9]{6}$", code);
var verified = await host.VerifyAsync(Alice, code);
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync());
var reset = await host.ResetAsync(Alice, code, NewPassword);
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
// A used code cannot be replayed.
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync());
Assert.Empty(await host.PendingCodesAsync());
}
[Fact]
public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var registered = await host.ForgetPasswordAsync(Alice);
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
Assert.Equal(registered.StatusCode, unregistered.StatusCode);
Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync());
Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync());
var message = Assert.Single(host.Sent.Messages);
Assert.Equal(Alice, message.To);
Assert.Single(await host.PendingCodesAsync(), row => row.UserId.Length > 0);
}
[Fact]
public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response()
{
await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender());
await host.AddUserAsync(Alice, OldPassword);
var registered = await host.ForgetPasswordAsync(Alice);
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
var body = await registered.Content.ReadAsStringAsync();
Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body);
Assert.DoesNotContain("smtp-internal-detail", body);
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Stored_code_is_keyed_with_a_server_secret_and_the_plaintext_is_only_in_the_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var row = Assert.Single(await host.PendingCodesAsync());
Assert.Equal(string.Empty, row.Code);
Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt);
Assert.Matches("^[0-9a-f]{64}$", row.CodeHash);
// Salt and hash from the row alone are not enough to test a guess offline.
Assert.NotEqual(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash);
Assert.Equal(
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(PasswordResetTestHost.JwtSecret), row.CodeSalt, code),
row.CodeHash);
Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId));
}
[Fact]
public async Task Code_expires_fifteen_minutes_after_it_is_issued()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1));
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
host.Time.Advance(TimeSpan.FromSeconds(1));
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
}
[Fact]
public async Task Requesting_a_new_code_invalidates_the_previous_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var first = host.Sent.LatestCodeFor(Alice);
string second;
do
{
await host.ForgetPasswordAsync(Alice);
second = host.Sent.LatestCodeFor(Alice);
}
while (second == first);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync());
Assert.Single(await host.PendingCodesAsync());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode);
}
[Fact]
public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var wrong = WrongCode(code);
// Verify and reset share one attempt budget.
for (var attempt = 0; attempt < 3; attempt++)
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync());
for (var attempt = 0; attempt < 2; attempt++)
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync());
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
await host.ForgetPasswordAsync(Alice);
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()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 4; attempt++)
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync());
// A correct verify does not spend an attempt, so the reset after it still succeeds.
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
}
[Fact]
public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 6; attempt++)
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
}
[Fact]
public async Task A_code_is_checked_only_against_the_email_it_was_issued_to()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.AddUserAsync(Bob, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync(Bob);
var aliceCode = host.Sent.LatestCodeFor(Alice);
var bobCode = host.Sent.LatestCodeFor(Bob);
if (aliceCode != bobCode)
{
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync());
}
// Exhausting Bob's attempts leaves Alice's code and budget untouched.
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Bob, WrongCode(bobCode));
var alice = Assert.Single(await host.PendingCodesAsync());
Assert.Equal(Alice, alice.Email);
Assert.Equal(0, alice.FailedAttempts);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode);
}
[Fact]
public async Task Concurrent_checks_can_never_consume_more_than_five_attempts()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var row = Assert.Single(await host.PendingCodesAsync());
var now = host.Time.GetUtcNow().UtcDateTime;
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
{
await using var scope = host.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IForgetPasswordDataService>()
.TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None);
}));
Assert.Equal(5, results.Count(consumed => consumed));
Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
}
[Fact]
public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
// The old client contract: code in the query string, no body, no content type.
var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}"));
Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode);
Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync());
Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
}
[Fact]
public async Task Email_and_code_in_the_query_string_are_ignored()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var queryRequest = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}"));
await host.WaitForEmailDrainAsync();
var bodyRequest = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, queryRequest.StatusCode);
Assert.Equal(await bodyRequest.Content.ReadAsStringAsync(), await queryRequest.Content.ReadAsStringAsync());
Assert.Empty(host.Sent.Messages);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var verified = await host.Client.SendAsync(PasswordResetTestHost.Post(
$"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}"));
Assert.Equal(HttpStatusCode.BadRequest, verified.StatusCode);
Assert.Equal(CodeNotMatched, await verified.Content.ReadAsStringAsync());
}
[Fact]
public async Task Each_endpoint_allows_ten_requests_per_client_per_window_then_answers_429()
{
await using var host = await PasswordResetTestHost.StartAsync();
const string client = "203.0.113.5";
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync($"n{request}@example.com", client)).StatusCode);
var limited = await host.ForgetPasswordAsync("n@example.com", client);
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
Assert.Equal(
"{\"status\":\"Error\",\"message\":\"Too many requests. Please try again later.\"}",
await limited.Content.ReadAsStringAsync());
// A left-hand X-Forwarded-For entry written by the caller does not buy a new bucket.
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.ForgetPasswordAsync("n@example.com", "198.51.100.1, " + client)).StatusCode);
// Another client, and the same client on another endpoint, are unaffected.
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync("n@example.com", "203.0.113.6")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync("n@example.com", "000000", client)).StatusCode);
}
[Theory]
[InlineData("api/Authentication/VerificationCode")]
[InlineData("api/Authentication/ResetPassword")]
public async Task Verify_and_reset_are_rate_limited_per_client(string path)
{
await using var host = await PasswordResetTestHost.StartAsync();
const string client = "203.0.113.9";
var body = new { email = Alice, code = "000000", password = NewPassword };
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
Assert.Equal(HttpStatusCode.BadRequest, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
}
[Fact]
public async Task No_code_or_email_reaches_the_logs_during_the_whole_flow()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync("nobody@example.com");
var code = host.Sent.LatestCodeFor(Alice);
await host.VerifyAsync(Alice, WrongCode(code));
await host.VerifyAsync(Alice, code);
await host.ResetAsync(Alice, code, NewPassword);
Assert.NotEmpty(host.Logged.Lines);
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(code, StringComparison.Ordinal));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(WrongCode(code), StringComparison.Ordinal));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains("@example.com", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(NewPassword, StringComparison.Ordinal));
}
[Fact]
public void Api_host_registers_forwarded_headers_and_rate_limiting_in_the_right_order()
{
var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs"));
Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", program);
Assert.Contains("builder.Services.AddPasswordResetEmailDelivery();", program);
var build = program.IndexOf("builder.Build()", StringComparison.Ordinal);
var forwarded = program.IndexOf("app.UseForwardedHeaders()", StringComparison.Ordinal);
var firstMiddleware = program.IndexOf("app.Use", build, StringComparison.Ordinal);
var routing = program.IndexOf("app.UseRouting()", StringComparison.Ordinal);
var limiter = program.IndexOf("app.UseRateLimiter()", StringComparison.Ordinal);
var controllers = program.IndexOf("app.MapControllers()", StringComparison.Ordinal);
Assert.True(forwarded > build && forwarded == firstMiddleware, "UseForwardedHeaders must be the first middleware.");
Assert.True(routing < limiter, "UseRateLimiter must run after UseRouting so endpoint policies apply.");
Assert.True(limiter < controllers);
}
private static string WrongCode(string code) =>
((int.Parse(code) + 1) % 1_000_000).ToString("D6");
private static string Sha256Hex(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string RepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null
&& !File.Exists(Path.Combine(directory.FullName, "Api.SeaHavenIndustries", "Program.cs")))
{
directory = directory.Parent;
}
return directory?.FullName
?? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory);
}
private sealed class ThrowingEmailSender : IEmailSender
{
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
throw new InvalidOperationException("smtp-internal-detail " + emailTo);
}
}