mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Merge pull request #193 from Sea-Haven-Industries/fix/ab/sh-403-reset-hardening-2
fix(auth): cap reset abuse per account, key reset-code hashes, send reset email off the request path
This commit is contained in:
commit
d3b6ba01c7
23 changed files with 1084 additions and 110 deletions
|
|
@ -237,8 +237,8 @@ public class AuthenticationControllerTests
|
|||
var service = new Mock<IAuthenticationService>();
|
||||
var controller = NewController(service);
|
||||
|
||||
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
|
||||
var unregistered = await controller.ForgetPassword(null, "x@y.com", CancellationToken.None);
|
||||
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None);
|
||||
var unregistered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "x@y.com" }, CancellationToken.None);
|
||||
|
||||
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
||||
|
|
@ -259,7 +259,7 @@ public class AuthenticationControllerTests
|
|||
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
||||
var controller = new AuthenticationController(service.Object, logger.Object);
|
||||
|
||||
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
|
||||
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None);
|
||||
|
||||
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
||||
|
|
@ -283,7 +283,7 @@ public class AuthenticationControllerTests
|
|||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
|
||||
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, CancellationToken.None);
|
||||
|
||||
if (matched)
|
||||
{
|
||||
|
|
@ -302,16 +302,16 @@ public class AuthenticationControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
|
||||
public async Task VerificationCode_WithoutABody_PassesNoEmailOrCodeToTheService()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None);
|
||||
var result = await controller.VerificationCode(null, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>()
|
||||
.Which.Message.Should().Be("Code Not Matched");
|
||||
service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny<CancellationToken>()), Times.Once);
|
||||
service.Verify(s => s.VerifyCodeAsync(null, null, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -322,7 +322,7 @@ public class AuthenticationControllerTests
|
|||
.ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, null, null, CancellationToken.None);
|
||||
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, CancellationToken.None);
|
||||
|
||||
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
|
||||
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public class AuthenticationServiceTests
|
|||
private static AuthenticationService NewService(
|
||||
Mock<IUserDataService> userData,
|
||||
Mock<IForgetPasswordDataService> forget,
|
||||
Mock<IEmailSender> email,
|
||||
Mock<IPasswordResetEmailQueue> email,
|
||||
out Mock<IUserRoleStore<ApplicationUser>> store,
|
||||
out Mock<IPasswordHasher<ApplicationUser>> hasher)
|
||||
{
|
||||
|
|
@ -28,7 +28,7 @@ public class AuthenticationServiceTests
|
|||
hasher = h;
|
||||
var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions);
|
||||
var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions);
|
||||
return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, TimeProvider.System, sessionStamps);
|
||||
return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, sessionStamps);
|
||||
}
|
||||
|
||||
private static JwtOptions JwtOptions => new()
|
||||
|
|
@ -41,7 +41,7 @@ public class AuthenticationServiceTests
|
|||
[Fact]
|
||||
public async Task Login_UnknownUser_ReturnsNull()
|
||||
{
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out _);
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out _);
|
||||
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
var result = await service.LoginAsync("nobody", "pw", CancellationToken.None);
|
||||
|
|
@ -53,7 +53,7 @@ public class AuthenticationServiceTests
|
|||
public async Task Login_DeletedUser_RejectedBeforePasswordCheck()
|
||||
{
|
||||
var deletedUser = IdentityTestHelpers.User(isDeleted: true);
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
|
||||
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(deletedUser);
|
||||
store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string>());
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ public class AuthenticationServiceTests
|
|||
public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity()
|
||||
{
|
||||
var user = IdentityTestHelpers.User();
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
|
||||
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
store.Setup(s => s.GetRolesAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string> { "Admin", "Manager" });
|
||||
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
|
||||
|
|
@ -90,7 +90,7 @@ public class AuthenticationServiceTests
|
|||
public async Task Login_BadPassword_ReturnsNull()
|
||||
{
|
||||
var user = IdentityTestHelpers.User();
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
|
||||
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
|
||||
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
|
||||
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
|
||||
|
|
@ -101,22 +101,24 @@ public class AuthenticationServiceTests
|
|||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
private static byte[] ResetKey => PasswordResetCodeSecrets.DeriveKey(JwtOptions.Secret);
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
|
||||
public async Task ForgetPassword_RegisteredEmail_StoresOnlyAKeyedHashAndQueuesTheCode()
|
||||
{
|
||||
var user = IdentityTestHelpers.User();
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
var email = new Mock<IEmailSender>();
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
string? stored = null, salt = null, body = null;
|
||||
DateTime expires = default;
|
||||
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, string, string, DateTime, CancellationToken>((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; })
|
||||
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; })
|
||||
.Returns(Task.CompletedTask);
|
||||
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
|
||||
email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny<string>()))
|
||||
.Callback<string, string, string>((_, _, b) => body = b)
|
||||
.ReturnsAsync(true);
|
||||
.Returns(true);
|
||||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
var before = DateTime.UtcNow;
|
||||
|
|
@ -126,25 +128,103 @@ public class AuthenticationServiceTests
|
|||
var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
|
||||
code.Should().HaveLength(6);
|
||||
stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$");
|
||||
PasswordResetCodeSecrets.Matches(salt!, code, stored!).Should().BeTrue();
|
||||
PasswordResetCodeSecrets.Matches(ResetKey, salt!, code, stored!).Should().BeTrue();
|
||||
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
|
||||
public async Task ForgetPassword_UnknownEmail_MakesTheSameDataCallsAndQueuesNothing()
|
||||
{
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
|
||||
var registered = new Mock<IForgetPasswordDataService>();
|
||||
var unregistered = new Mock<IForgetPasswordDataService>();
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
|
||||
|
||||
await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
|
||||
registered.Invocations.Select(call => call.Method.Name)
|
||||
.Should().Equal(unregistered.Invocations.Select(call => call.Method.Name))
|
||||
.And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync));
|
||||
unregistered.Verify(f => f.ReplaceCodeAsync("nope@example.com", string.Empty, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_EmailThatCannotBeQueued_DoesNotCountTheRequest()
|
||||
{
|
||||
var user = IdentityTestHelpers.User();
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
var email = new Mock<IEmailSender>();
|
||||
var stored = new List<(string Hash, string Salt)>();
|
||||
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, _, _, _) => stored.Add((h, s)))
|
||||
.Returns(Task.CompletedTask);
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
var bodies = new List<string>();
|
||||
// The queue is full for the first three requests.
|
||||
email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns<string, string, string>((_, _, b) =>
|
||||
{
|
||||
bodies.Add(b);
|
||||
return bodies.Count > 3;
|
||||
});
|
||||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
for (var request = 0; request < 4; request++)
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
// The three undelivered requests did not use up the hourly limit of three, and every
|
||||
// email carries the code stored just before it was queued.
|
||||
email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4));
|
||||
stored.Should().HaveCount(4);
|
||||
for (var request = 0; request < 4; request++)
|
||||
{
|
||||
var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
|
||||
PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeTrue();
|
||||
}
|
||||
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ForgetPassword_WriteThatFailsOrIsCancelled_QueuesNoEmail(bool cancelled)
|
||||
{
|
||||
var user = IdentityTestHelpers.User();
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")));
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
|
||||
|
||||
var act = () => NewService(userData, forget, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<Exception>();
|
||||
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail()
|
||||
{
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
|
||||
var registered = new Mock<IForgetPasswordDataService>();
|
||||
var unregistered = new Mock<IForgetPasswordDataService>();
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(false);
|
||||
|
||||
await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
|
||||
registered.Invocations.Select(call => call.Method.Name)
|
||||
.Should().Equal(unregistered.Invocations.Select(call => call.Method.Name))
|
||||
.And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -154,13 +234,13 @@ public class AuthenticationServiceTests
|
|||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(IdentityTestHelpers.User(isDeleted: true));
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
var email = new Mock<IEmailSender>();
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -173,7 +253,7 @@ public class AuthenticationServiceTests
|
|||
{
|
||||
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
|
||||
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
|
||||
|
||||
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
|
||||
|
||||
|
|
@ -186,7 +266,7 @@ public class AuthenticationServiceTests
|
|||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.GetByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ForgetPasswordCode?)null);
|
||||
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out var store, out _);
|
||||
|
||||
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
|
||||
|
||||
|
|
@ -198,12 +278,12 @@ public class AuthenticationServiceTests
|
|||
[Fact]
|
||||
public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails()
|
||||
{
|
||||
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash("s", "123456"), FailedAttempts = 5 };
|
||||
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456"), FailedAttempts = 5 };
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
|
||||
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out var store, out _);
|
||||
|
||||
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
||||
|
||||
|
|
@ -212,6 +292,84 @@ public class AuthenticationServiceTests
|
|||
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task VerifyCode_FailedOrCancelledLookups_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
|
||||
{
|
||||
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
var lookups = 0;
|
||||
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>()))
|
||||
.Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay
|
||||
? Task.FromException<ForgetPasswordCode?>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
|
||||
: Task.FromResult<ForgetPasswordCode?>(pending));
|
||||
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
|
||||
|
||||
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
|
||||
{
|
||||
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<Exception>();
|
||||
}
|
||||
|
||||
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task VerifyCode_FailedOrCancelledAttemptConsumes_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
|
||||
{
|
||||
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
|
||||
var consumes = 0;
|
||||
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay
|
||||
? Task.FromException<bool>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
|
||||
: Task.FromResult(true));
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
|
||||
|
||||
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
|
||||
{
|
||||
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<Exception>();
|
||||
}
|
||||
|
||||
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ForgetPassword_FailedOrCancelledWrites_DoNotUseUpTheEmailRequestLimit(bool cancelled)
|
||||
{
|
||||
var user = IdentityTestHelpers.User();
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
var writes = 0;
|
||||
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour
|
||||
? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
|
||||
: Task.CompletedTask);
|
||||
var email = new Mock<IPasswordResetEmailQueue>();
|
||||
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
|
||||
for (var request = 0; request < InMemoryPasswordResetThrottle.CodeRequestsPerHour; request++)
|
||||
{
|
||||
var act = () => service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<Exception>();
|
||||
}
|
||||
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1);
|
||||
forget.Verify(f => f.PurgeExpiredAsync(It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123456", "123456", true)]
|
||||
[InlineData("123456", " 123456 ", true)]
|
||||
|
|
@ -219,11 +377,11 @@ public class AuthenticationServiceTests
|
|||
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
|
||||
{
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
var hash = PasswordResetCodeSecrets.Hash(salt, issued);
|
||||
var hash = PasswordResetCodeSecrets.Hash(ResetKey, salt, issued);
|
||||
|
||||
PasswordResetCodeSecrets.Matches(salt, candidate, hash).Should().Be(expected);
|
||||
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
|
||||
PasswordResetCodeSecrets.Matches("", issued, hash).Should().BeFalse();
|
||||
PasswordResetCodeSecrets.Matches(salt, issued, "").Should().BeFalse();
|
||||
PasswordResetCodeSecrets.Matches(ResetKey, salt, candidate, hash).Should().Be(expected);
|
||||
PasswordResetCodeSecrets.Hash(ResetKey, PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
|
||||
PasswordResetCodeSecrets.Matches(ResetKey, "", issued, hash).Should().BeFalse();
|
||||
PasswordResetCodeSecrets.Matches(ResetKey, salt, issued, "").Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<IForgetPasswordDataService>(),
|
||||
Mock.Of<IEmailSender>(),
|
||||
Mock.Of<IPasswordResetEmailQueue>(),
|
||||
new InMemoryPasswordResetThrottle(TimeProvider.System),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
|
|
@ -180,7 +181,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
Email = user.Email!,
|
||||
UserId = user.Id,
|
||||
CodeSalt = salt,
|
||||
CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"),
|
||||
CodeHash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('x', 64)), salt, "123456"),
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10)
|
||||
});
|
||||
forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
|
|
@ -221,7 +222,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
|
||||
Mock.Of<IUserDataService>(),
|
||||
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
|
||||
Mock.Of<IEmailSender>(),
|
||||
Mock.Of<IPasswordResetEmailQueue>(),
|
||||
new InMemoryPasswordResetThrottle(TimeProvider.System),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class PasswordResetEmailChannelTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_full_queue_refuses_the_email_instead_of_dropping_it_silently()
|
||||
{
|
||||
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
|
||||
for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++)
|
||||
channel.TryEnqueue($"user{i}@example.com", "subject", "body").Should().BeTrue();
|
||||
|
||||
var accepted = channel.TryEnqueue("late@example.com", "subject", "body");
|
||||
|
||||
accepted.Should().BeFalse();
|
||||
channel.Pending.Should().Be(PasswordResetEmailChannel.Capacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_email_is_never_delivered_and_the_queue_accepts_again_once_drained()
|
||||
{
|
||||
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
|
||||
for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++)
|
||||
channel.TryEnqueue($"user{i}@example.com", "subject", "body");
|
||||
channel.TryEnqueue("late@example.com", "subject", "body").Should().BeFalse();
|
||||
|
||||
var delivered = new List<string>();
|
||||
while (channel.Reader.TryRead(out var email))
|
||||
{
|
||||
delivered.Add(email.EmailTo);
|
||||
channel.MarkHandled();
|
||||
}
|
||||
|
||||
delivered.Should().HaveCount(PasswordResetEmailChannel.Capacity).And.NotContain("late@example.com");
|
||||
channel.Pending.Should().Be(0);
|
||||
channel.TryEnqueue("retry@example.com", "subject", "body").Should().BeTrue();
|
||||
(await channel.Reader.ReadAsync()).EmailTo.Should().Be("retry@example.com");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Sentry;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class PasswordResetEmailSenderTracingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Each_send_finishes_its_transaction_as_ok_only_when_the_provider_accepts_it(bool accepted)
|
||||
{
|
||||
var transaction = new Mock<ITransactionTracer>();
|
||||
var hub = new Mock<IHub>();
|
||||
hub.Setup(h => h.PushScope()).Returns(Mock.Of<IDisposable>());
|
||||
hub.Setup(h => h.StartTransaction(It.IsAny<ITransactionContext>(), It.IsAny<IReadOnlyDictionary<string, object?>>()))
|
||||
.Returns(transaction.Object);
|
||||
|
||||
var sender = new Mock<IEmailSender>();
|
||||
sender.Setup(s => s.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(accepted);
|
||||
var services = new ServiceCollection().AddSingleton(sender.Object).BuildServiceProvider();
|
||||
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
|
||||
var worker = new PasswordResetEmailSenderHostedService(
|
||||
channel,
|
||||
services.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<PasswordResetEmailSenderHostedService>.Instance,
|
||||
hub.Object);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
Assert.True(channel.TryEnqueue("user@example.com", "subject", "Your code is 123456"));
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (channel.Pending > 0 && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, channel.Pending);
|
||||
if (accepted)
|
||||
{
|
||||
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once);
|
||||
transaction.Verify(t => t.Finish(It.IsAny<Exception>(), It.IsAny<SpanStatus>()), Times.Never);
|
||||
}
|
||||
else
|
||||
{
|
||||
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Never);
|
||||
transaction.Verify(t => t.Finish(
|
||||
It.Is<Exception>(ex => !ex.Message.Contains("user@example.com") && !ex.Message.Contains("123456")),
|
||||
SpanStatus.InternalError), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ public class SentryPipelineContractTests
|
|||
[InlineData("Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs", "workorders.week-rolled-job")]
|
||||
[InlineData("Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs", "workorders.past-due-cache-job")]
|
||||
[InlineData("Api.SeaHavenIndustries/HostedServices/UpliftLifecycleHostedService.cs", "uplifts.lifecycle-sweep")]
|
||||
[InlineData("Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs", "auth.password-reset-email")]
|
||||
public void Workers_UseSharedBackgroundTransactionHelper_WithStableNames(string relativePath, string transactionName)
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(RepoRoot(), relativePath));
|
||||
|
|
|
|||
|
|
@ -115,20 +115,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
public const string ForgetPasswordMessage =
|
||||
"If that email belongs to an account, a reset code has been sent to it.";
|
||||
|
||||
// Email and code are read from the JSON body so they stay out of URLs and proxy
|
||||
// access logs; the query-string form is still accepted for older clients.
|
||||
// Email and code are read only from the JSON body, so they never appear in URLs
|
||||
// or in proxy and load balancer access logs.
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("ForgetPassword")]
|
||||
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
|
||||
public async Task<IActionResult> ForgetPassword(
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
|
||||
[FromQuery(Name = "Email")] string? email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken);
|
||||
await _authenticationService.ForgetPasswordAsync(body?.Email, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -146,13 +145,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)]
|
||||
public async Task<IActionResult> VerificationCode(
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body,
|
||||
[FromQuery] string? email,
|
||||
[FromQuery] string? code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await _authenticationService.VerifyCodeAsync(body?.Email ?? email, body?.Code ?? code, cancellationToken))
|
||||
if (await _authenticationService.VerifyCodeAsync(body?.Email, body?.Code, cancellationToken))
|
||||
{
|
||||
|
||||
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
using System.Threading.Channels;
|
||||
using Api.SeaHavenIndustries.Observability;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Sentry;
|
||||
|
||||
namespace Api.SeaHavenIndustries.HostedServices
|
||||
{
|
||||
public static class PasswordResetEmailDelivery
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the process-wide reset email queue and the background service that
|
||||
/// drains it. Both must be singletons: the request and the sender share one channel.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<PasswordResetEmailChannel>();
|
||||
services.AddSingleton<IPasswordResetEmailQueue>(provider => provider.GetRequiredService<PasswordResetEmailChannel>());
|
||||
services.AddHostedService<PasswordResetEmailSenderHostedService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PasswordResetEmail(string EmailTo, string Subject, string Body);
|
||||
|
||||
public sealed class PasswordResetEmailChannel : IPasswordResetEmailQueue
|
||||
{
|
||||
public const int Capacity = 1000;
|
||||
|
||||
private readonly Channel<PasswordResetEmail> _channel = Channel.CreateBounded<PasswordResetEmail>(
|
||||
new BoundedChannelOptions(Capacity)
|
||||
{
|
||||
// Wait, not DropWrite: with DropWrite, TryWrite reports success and discards
|
||||
// the email, so a full queue would still count the request. TryWrite never
|
||||
// blocks; under Wait it returns false when the queue is full.
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = true
|
||||
});
|
||||
private readonly ILogger<PasswordResetEmailChannel> _logger;
|
||||
private int _pending;
|
||||
|
||||
public PasswordResetEmailChannel(ILogger<PasswordResetEmailChannel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ChannelReader<PasswordResetEmail> Reader => _channel.Reader;
|
||||
|
||||
/// <summary>Emails accepted and not yet handed to the mail provider.</summary>
|
||||
public int Pending => Volatile.Read(ref _pending);
|
||||
|
||||
public bool TryEnqueue(string emailTo, string subject, string body)
|
||||
{
|
||||
Interlocked.Increment(ref _pending);
|
||||
if (_channel.Writer.TryWrite(new PasswordResetEmail(emailTo, subject, body)))
|
||||
return true;
|
||||
|
||||
Interlocked.Decrement(ref _pending);
|
||||
_logger.LogWarning("Password reset email queue is full; an email was dropped.");
|
||||
return false;
|
||||
}
|
||||
|
||||
public void MarkHandled() => Interlocked.Decrement(ref _pending);
|
||||
}
|
||||
|
||||
public sealed class PasswordResetEmailSenderHostedService : BackgroundService
|
||||
{
|
||||
private readonly PasswordResetEmailChannel _channel;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<PasswordResetEmailSenderHostedService> _logger;
|
||||
private readonly IHub _sentryHub;
|
||||
|
||||
public PasswordResetEmailSenderHostedService(
|
||||
PasswordResetEmailChannel channel,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<PasswordResetEmailSenderHostedService> logger,
|
||||
IHub sentryHub)
|
||||
{
|
||||
_channel = channel;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_sentryHub = sentryHub;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
using var transaction = SentryObservability.BeginBackgroundTransaction(
|
||||
_sentryHub,
|
||||
"auth.password-reset-email",
|
||||
$"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}");
|
||||
try
|
||||
{
|
||||
if (await SendAsync(email))
|
||||
transaction.FinishOk();
|
||||
else
|
||||
transaction.FinishError(new InvalidOperationException("The mail provider did not accept the password reset email."));
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
transaction.FinishCancelled();
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The message can echo the recipient or the body, so only the type is logged.
|
||||
_logger.LogError("Password reset email failed with {ExceptionType}.", ex.GetType().FullName);
|
||||
transaction.FinishError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_channel.MarkHandled();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the mail provider accepted the email.</summary>
|
||||
private async Task<bool> SendAsync(PasswordResetEmail email)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
|
||||
if (await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
|
||||
return true;
|
||||
|
||||
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ builder.Services.AddResponseCompression(opts =>
|
|||
new[] { "application/octet-stream" });
|
||||
});
|
||||
builder.Services.AddPasswordResetRateLimiting();
|
||||
builder.Services.AddPasswordResetEmailDelivery();
|
||||
builder.Services.AddCors(option =>
|
||||
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,17 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
|
||||
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = Normalize(email);
|
||||
|
||||
var existing = await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||
.ToListAsync(cancellationToken);
|
||||
_context.ForgetPasswordCodes.RemoveRange(existing);
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// Every request also clears expired codes of any email, which keeps the
|
||||
// table bounded and gives each request the same database work.
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail || u.ExpiresAtUtc <= nowUtc)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
|
||||
{
|
||||
|
|
@ -33,6 +36,14 @@ namespace SeaHaven.DataServices.Implementation
|
|||
FailedAttempts = 0
|
||||
});
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(u => u.ExpiresAtUtc <= nowUtc)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IForgetPasswordDataService
|
||||
{
|
||||
/// <summary>Deletes every pending code for the email, then stores the new one.</summary>
|
||||
Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// In one transaction, deletes every pending code for the email and every expired
|
||||
/// code, then stores the new one.
|
||||
/// </summary>
|
||||
Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken);
|
||||
|
||||
Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the pending code for exactly this email, or null.</summary>
|
||||
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ namespace SeaHaven.Services.DependencyInjection
|
|||
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
// Process-wide counters: a scoped instance would start empty on every request.
|
||||
services.AddSingleton<IPasswordResetThrottle, Helpers.InMemoryPasswordResetThrottle>();
|
||||
|
||||
services.AddScoped<IWorkOrderReconciliationRunner>(
|
||||
sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService<IWorkOrderReconciliationService>());
|
||||
|
||||
|
|
|
|||
130
SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs
Normal file
130
SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Process-wide sliding-window counters for <see cref="IPasswordResetThrottle"/>.
|
||||
/// Registered as a singleton; the API runs as a single instance, and a restart
|
||||
/// clears the windows. Emails are held only as SHA-256 digests.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPasswordResetThrottle : IPasswordResetThrottle
|
||||
{
|
||||
public const int CodeRequestsPerHour = 3;
|
||||
public const int CodeRequestsPerDay = 10;
|
||||
public const int FailedChecksPerDay = 10;
|
||||
|
||||
private static readonly TimeSpan Hour = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan Day = TimeSpan.FromDays(1);
|
||||
private const int SweepEvery = 1024;
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, Account> _accounts = new(StringComparer.Ordinal);
|
||||
private int _operations;
|
||||
|
||||
public InMemoryPasswordResetThrottle(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public bool TryAcceptCodeRequest(string email)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
var account = AccountFor(email, now);
|
||||
if (account.Requests.Count >= CodeRequestsPerDay
|
||||
|| account.Requests.Count(at => at > now - Hour) >= CodeRequestsPerHour)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
account.Requests.Add(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReleaseCodeRequest(string email)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
var account = AccountFor(email, now);
|
||||
if (account.Requests.Count > 0)
|
||||
account.Requests.RemoveAt(account.Requests.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReserveCheck(string email)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
var account = AccountFor(email, now);
|
||||
if (account.FailedChecks.Count >= FailedChecksPerDay)
|
||||
return false;
|
||||
|
||||
account.FailedChecks.Add(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReleaseCheck(string email)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
var account = AccountFor(email, now);
|
||||
if (account.FailedChecks.Count > 0)
|
||||
account.FailedChecks.RemoveAt(account.FailedChecks.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The same normalization the user lookup applies: trimmed, invariant upper case.</summary>
|
||||
public static string KeyFor(string email)
|
||||
{
|
||||
var normalized = (email ?? string.Empty).Trim().ToUpperInvariant();
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
||||
}
|
||||
|
||||
private Account AccountFor(string email, DateTimeOffset now)
|
||||
{
|
||||
if (++_operations % SweepEvery == 0)
|
||||
Sweep(now);
|
||||
|
||||
var key = KeyFor(email);
|
||||
if (!_accounts.TryGetValue(key, out var account))
|
||||
{
|
||||
account = new Account();
|
||||
_accounts[key] = account;
|
||||
}
|
||||
|
||||
account.Prune(now - Day);
|
||||
return account;
|
||||
}
|
||||
|
||||
private void Sweep(DateTimeOffset now)
|
||||
{
|
||||
foreach (var (key, account) in _accounts.ToList())
|
||||
{
|
||||
account.Prune(now - Day);
|
||||
if (account.Requests.Count == 0 && account.FailedChecks.Count == 0)
|
||||
_accounts.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Account
|
||||
{
|
||||
public List<DateTimeOffset> Requests { get; } = new();
|
||||
public List<DateTimeOffset> FailedChecks { get; } = new();
|
||||
|
||||
public void Prune(DateTimeOffset cutoff)
|
||||
{
|
||||
Requests.RemoveAll(at => at <= cutoff);
|
||||
FailedChecks.RemoveAll(at => at <= cutoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,24 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
/// <summary>
|
||||
/// Generation and hashing for emailed password reset codes. The raw code exists
|
||||
/// only in memory and in the email sent to the account holder.
|
||||
/// only in memory and in the email sent to the account holder. Hashes are keyed
|
||||
/// with a server-side key, so a copy of the database alone cannot be used to
|
||||
/// brute-force the six-digit codes offline.
|
||||
/// </summary>
|
||||
public static class PasswordResetCodeSecrets
|
||||
{
|
||||
private static readonly byte[] KeyInfo = Encoding.UTF8.GetBytes("password-reset-code-v1");
|
||||
|
||||
/// <summary>
|
||||
/// Derives the code-hashing key from an existing server secret with HKDF, so no
|
||||
/// new secret is needed and the derived key is useless for anything else.
|
||||
/// </summary>
|
||||
public static byte[] DeriveKey(string serverSecret)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(serverSecret);
|
||||
return HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(serverSecret), 32, Array.Empty<byte>(), KeyInfo);
|
||||
}
|
||||
|
||||
public static string NewCode()
|
||||
{
|
||||
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
|
||||
|
|
@ -20,19 +34,26 @@ namespace SeaHaven.Services.Helpers
|
|||
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Hash(string salt, string code)
|
||||
/// <summary>A value shaped like a hash that no code can match.</summary>
|
||||
public static string NewUnmatchableHash()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(salt);
|
||||
ArgumentNullException.ThrowIfNull(code);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
|
||||
return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool Matches(string salt, string candidate, string expectedHash)
|
||||
public static string Hash(byte[] key, string salt, string code)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(salt);
|
||||
ArgumentNullException.ThrowIfNull(code);
|
||||
return Convert.ToHexString(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool Matches(byte[] key, string salt, string candidate, string expectedHash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash))
|
||||
return false;
|
||||
|
||||
var actual = Encoding.ASCII.GetBytes(Hash(salt, candidate.Trim()));
|
||||
var actual = Encoding.ASCII.GetBytes(Hash(key, salt, candidate.Trim()));
|
||||
var expected = Encoding.ASCII.GetBytes(expectedHash);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,19 +19,25 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly JwtOptions _jwtOptions;
|
||||
private readonly IUserDataService _userDataService;
|
||||
private readonly IForgetPasswordDataService _forgetPasswordDataService;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly IPasswordResetEmailQueue _resetEmails;
|
||||
private readonly IPasswordResetThrottle _resetThrottle;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ISessionStampService _sessionStamps;
|
||||
private byte[]? _resetCodeKey;
|
||||
|
||||
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
|
||||
public const int MaxCodeAttempts = 5;
|
||||
|
||||
/// <summary>Longest address stored for a reset request; Identity caps emails at 256.</summary>
|
||||
public const int MaxResetEmailLength = 256;
|
||||
|
||||
public AuthenticationService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
IUserDataService userDataService,
|
||||
IForgetPasswordDataService forgetPasswordDataService,
|
||||
IEmailSender emailSender,
|
||||
IPasswordResetEmailQueue resetEmails,
|
||||
IPasswordResetThrottle resetThrottle,
|
||||
TimeProvider timeProvider,
|
||||
ISessionStampService sessionStamps)
|
||||
{
|
||||
|
|
@ -39,11 +45,14 @@ namespace SeaHaven.Services.Implementation
|
|||
_jwtOptions = jwtOptions.Value;
|
||||
_userDataService = userDataService;
|
||||
_forgetPasswordDataService = forgetPasswordDataService;
|
||||
_emailSender = emailSender;
|
||||
_resetEmails = resetEmails;
|
||||
_resetThrottle = resetThrottle;
|
||||
_timeProvider = timeProvider;
|
||||
_sessionStamps = sessionStamps;
|
||||
}
|
||||
|
||||
private byte[] ResetCodeKey => _resetCodeKey ??= PasswordResetCodeSecrets.DeriveKey(_jwtOptions.Secret);
|
||||
|
||||
public async Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userManager.FindByNameAsync(username ?? "");
|
||||
|
|
@ -156,26 +165,58 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken)
|
||||
{
|
||||
// Registered and unregistered addresses take the same path up to the
|
||||
// email send: one user lookup, one code generated and hashed, one write.
|
||||
// Registered and unregistered addresses do the same work: one user lookup,
|
||||
// one code generated and hashed, and the same replace in the database. An
|
||||
// unregistered address gets a row no code can match. The email itself is
|
||||
// queued, so the response never waits on the mail provider.
|
||||
var requested = email?.Trim() ?? string.Empty;
|
||||
var user = requested.Length == 0
|
||||
? null
|
||||
: await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
|
||||
var code = PasswordResetCodeSecrets.NewCode();
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
var hash = PasswordResetCodeSecrets.Hash(salt, code);
|
||||
if (requested.Length == 0 || requested.Length > MaxResetEmailLength)
|
||||
return;
|
||||
|
||||
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
|
||||
var user = await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
|
||||
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
if (!_resetThrottle.TryAcceptCodeRequest(requested))
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken);
|
||||
// Over the per-email limit: keep the current code and send nothing.
|
||||
await _forgetPasswordDataService.PurgeExpiredAsync(nowUtc, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime);
|
||||
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken);
|
||||
var code = PasswordResetCodeSecrets.NewCode();
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code);
|
||||
var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email);
|
||||
|
||||
// The email is queued only after its code is stored, so a failed or cancelled
|
||||
// write never sends a code that cannot be used. The request stays counted only
|
||||
// when the row is stored and, for an account, its email was queued; when the
|
||||
// queue is full the stored code is left unsent and the user can ask again.
|
||||
var counted = false;
|
||||
try
|
||||
{
|
||||
await _forgetPasswordDataService.ReplaceCodeAsync(
|
||||
active ? user!.Email! : requested,
|
||||
active ? user!.Id : string.Empty,
|
||||
active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
|
||||
salt,
|
||||
nowUtc.Add(ResetCodeLifetime),
|
||||
nowUtc,
|
||||
cancellationToken);
|
||||
|
||||
var queued = false;
|
||||
if (active)
|
||||
{
|
||||
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
|
||||
await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body);
|
||||
queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body);
|
||||
}
|
||||
|
||||
counted = queued || !active;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!counted)
|
||||
_resetThrottle.ReleaseCodeRequest(requested);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
|
||||
|
|
@ -231,6 +272,17 @@ namespace SeaHaven.Services.Implementation
|
|||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code))
|
||||
return null;
|
||||
|
||||
// The per-account budget spans every code the account is sent, so asking for
|
||||
// new codes does not buy more guesses. A slot is reserved before comparing and
|
||||
// given back when the code matches or there is no live code to guess at.
|
||||
if (!_resetThrottle.TryReserveCheck(email))
|
||||
return null;
|
||||
|
||||
// Only a wrong code actually compared keeps the slot. No live code, an expired or
|
||||
// used-up code, a match, and a failed or cancelled call all give it back.
|
||||
var wrongGuess = false;
|
||||
try
|
||||
{
|
||||
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
||||
if (pending == null)
|
||||
return null;
|
||||
|
|
@ -244,13 +296,20 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
}
|
||||
|
||||
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
|
||||
if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash))
|
||||
return pending;
|
||||
|
||||
wrongGuess = true;
|
||||
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!wrongGuess)
|
||||
_resetThrottle.ReleaseCheck(email);
|
||||
}
|
||||
}
|
||||
|
||||
private JwtSecurityToken GetToken(List<Claim> authClaims)
|
||||
{
|
||||
|
|
|
|||
13
SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs
Normal file
13
SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Hands a password reset email to a background sender, so the request that asked
|
||||
/// for it does not wait on the mail provider and cannot be timed against one that
|
||||
/// sent nothing.
|
||||
/// </summary>
|
||||
public interface IPasswordResetEmailQueue
|
||||
{
|
||||
/// <summary>Returns false when the queue is full and the email was dropped.</summary>
|
||||
bool TryEnqueue(string emailTo, string subject, string body);
|
||||
}
|
||||
}
|
||||
30
SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs
Normal file
30
SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-account limits on the anonymous password reset flow, keyed on the
|
||||
/// normalized email so they hold however many client addresses an attacker uses.
|
||||
/// </summary>
|
||||
public interface IPasswordResetThrottle
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts a code request for the email and returns true while it is within the
|
||||
/// hourly and daily limits. A refused request is not counted.
|
||||
/// </summary>
|
||||
bool TryAcceptCodeRequest(string email);
|
||||
|
||||
/// <summary>Gives back an accepted code request whose email could not be queued.</summary>
|
||||
void ReleaseCodeRequest(string email);
|
||||
|
||||
/// <summary>
|
||||
/// Reserves one failed check for the email before a code is compared. Returns
|
||||
/// false once the account has used its failed checks for the window.
|
||||
/// </summary>
|
||||
bool TryReserveCheck(string email);
|
||||
|
||||
/// <summary>
|
||||
/// Gives back a reservation that did not become a failed guess: the code matched,
|
||||
/// or there was no live code to compare it with.
|
||||
/// </summary>
|
||||
void ReleaseCheck(string email);
|
||||
}
|
||||
}
|
||||
269
SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs
Normal file
269
SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ 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;
|
||||
|
|
@ -64,7 +65,7 @@ public sealed class PasswordResetFlowTests
|
|||
|
||||
var message = Assert.Single(host.Sent.Messages);
|
||||
Assert.Equal(Alice, message.To);
|
||||
Assert.Single(await host.PendingCodesAsync());
|
||||
Assert.Single(await host.PendingCodesAsync(), row => row.UserId.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -84,7 +85,7 @@ public sealed class PasswordResetFlowTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email()
|
||||
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);
|
||||
|
|
@ -96,8 +97,11 @@ public sealed class PasswordResetFlowTests
|
|||
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);
|
||||
// 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));
|
||||
}
|
||||
|
||||
|
|
@ -289,18 +293,25 @@ public sealed class PasswordResetFlowTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_and_ForgetPassword_still_accept_the_query_string_form()
|
||||
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 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 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.OK, verified.StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verified.StatusCode);
|
||||
Assert.Equal(CodeNotMatched, await verified.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -367,6 +378,7 @@ public sealed class PasswordResetFlowTests
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -92,8 +92,11 @@ public sealed class PasswordResetRaceTests
|
|||
_race = race;
|
||||
}
|
||||
|
||||
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken) =>
|
||||
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, cancellationToken);
|
||||
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken) =>
|
||||
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, nowUtc, cancellationToken);
|
||||
|
||||
public Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken) =>
|
||||
_inner.PurgeExpiredAsync(nowUtc, cancellationToken);
|
||||
|
||||
public Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
_inner.GetByEmailAsync(email, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
|
@ -32,6 +33,8 @@ namespace SeaHavenIndustries.Tests;
|
|||
/// </summary>
|
||||
internal sealed class PasswordResetTestHost : IAsyncDisposable
|
||||
{
|
||||
public static readonly string JwtSecret = new('k', 64);
|
||||
|
||||
private readonly WebApplication _app;
|
||||
private readonly string _databasePath;
|
||||
|
||||
|
|
@ -61,7 +64,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
|||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JWT:Secret"] = new string('k', 64),
|
||||
["JWT:Secret"] = JwtSecret,
|
||||
["JWT:ValidIssuer"] = "issuer",
|
||||
["JWT:ValidAudience"] = "audience"
|
||||
});
|
||||
|
|
@ -91,6 +94,8 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
|||
manager.FeatureProviders.Add(new OnlyAuthenticationController());
|
||||
});
|
||||
builder.Services.AddPasswordResetRateLimiting();
|
||||
builder.Services.AddSingleton<Sentry.IHub>(Sentry.Extensibility.HubAdapter.Instance);
|
||||
builder.Services.AddPasswordResetEmailDelivery();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
|
|
@ -142,7 +147,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
|||
/// Stores a second pending code for the email directly, as two concurrent first
|
||||
/// requests could, and returns it. The new row is the newest one.
|
||||
/// </summary>
|
||||
public async Task<string> AddSiblingCodeAsync(string email, string userId)
|
||||
public async Task<string> AddSiblingCodeAsync(string email, string userId, bool keyedHash = true)
|
||||
{
|
||||
const string code = "424242";
|
||||
const string salt = "0123456789abcdef0123456789abcdef";
|
||||
|
|
@ -153,7 +158,9 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
|||
Email = email,
|
||||
UserId = userId,
|
||||
CodeSalt = salt,
|
||||
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
|
||||
CodeHash = keyedHash
|
||||
? SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(SeaHaven.Services.Helpers.PasswordResetCodeSecrets.DeriveKey(JwtSecret), salt, code)
|
||||
: Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant(),
|
||||
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
|
@ -170,8 +177,25 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
|
|||
return request;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
|
||||
/// <summary>Requests a code and waits until any queued email has been handed to the sender.</summary>
|
||||
public async Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null)
|
||||
{
|
||||
var response = await Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
|
||||
await WaitForEmailDrainAsync();
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task WaitForEmailDrainAsync()
|
||||
{
|
||||
var channel = _app.Services.GetRequiredService<PasswordResetEmailChannel>();
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (channel.Pending > 0)
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
throw new TimeoutException("Queued password reset emails were not sent.");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> VerifyAsync(string email, string code, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System.Text;
|
|||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
|
@ -83,6 +84,9 @@ internal sealed class SessionTestHost : IAsyncDisposable
|
|||
builder.Services.AddSingleton<IEmailSender>(sent);
|
||||
builder.Services.AddDataServices();
|
||||
builder.Services.AddBusinessServices(builder.Configuration);
|
||||
// Forgot Password queues its email; the API host registers the same delivery.
|
||||
builder.Services.AddSingleton<Sentry.IHub>(Sentry.Extensibility.HubAdapter.Instance);
|
||||
builder.Services.AddPasswordResetEmailDelivery();
|
||||
builder.Services.AddSeaHavenJwtAuthentication(builder.Configuration);
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(AuthenticationController).Assembly)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
|
@ -79,6 +80,9 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
|
|||
services.AddSingleton<IEmailSender>(fakes.Sent);
|
||||
services.AddDataServices();
|
||||
services.AddBusinessServices(configuration);
|
||||
// Registration signs the member in through the authentication service, which
|
||||
// queues password reset email; the API host registers the same delivery.
|
||||
services.AddPasswordResetEmailDelivery();
|
||||
configure?.Invoke(services);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue