mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows.
375 lines
18 KiB
C#
375 lines
18 KiB
C#
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.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());
|
|
}
|
|
|
|
[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_a_salted_hash_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);
|
|
Assert.NotEqual(Sha256Hex(code), row.CodeHash);
|
|
Assert.Equal(Sha256Hex(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 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 VerificationCode_and_ForgetPassword_still_accept_the_query_string_form()
|
|
{
|
|
await using var host = await PasswordResetTestHost.StartAsync();
|
|
await host.AddUserAsync(Alice, OldPassword);
|
|
|
|
var requested = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}"));
|
|
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
|
|
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.OK, verified.StatusCode);
|
|
}
|
|
|
|
[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);
|
|
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);
|
|
}
|
|
}
|